svc-infra 0.1.706__py3-none-any.whl → 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
svc_infra/db/nosql/repository.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import builtins
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from datetime import UTC
|
|
6
|
+
from typing import Any, cast
|
|
4
7
|
|
|
5
8
|
try:
|
|
6
9
|
from bson import ObjectId
|
|
@@ -35,7 +38,7 @@ class NoSqlRepository:
|
|
|
35
38
|
soft_delete: bool = False,
|
|
36
39
|
soft_delete_field: str = "deleted_at",
|
|
37
40
|
soft_delete_flag_field: str | None = None,
|
|
38
|
-
immutable_fields:
|
|
41
|
+
immutable_fields: set[str] | None = None,
|
|
39
42
|
):
|
|
40
43
|
self.collection_name = collection_name
|
|
41
44
|
self.id_field = id_field
|
|
@@ -46,7 +49,7 @@ class NoSqlRepository:
|
|
|
46
49
|
immutable_fields or {self.id_field, "created_at", "updated_at"}
|
|
47
50
|
)
|
|
48
51
|
|
|
49
|
-
def _alive_filter(self) ->
|
|
52
|
+
def _alive_filter(self) -> dict[str, Any]:
|
|
50
53
|
"""
|
|
51
54
|
Build a filter that returns 'alive' docs when soft_delete is enabled.
|
|
52
55
|
- deleted_at is either null or absent
|
|
@@ -84,7 +87,7 @@ class NoSqlRepository:
|
|
|
84
87
|
|
|
85
88
|
return clauses[0] if len(clauses) == 1 else {"$and": clauses}
|
|
86
89
|
|
|
87
|
-
def _merge_and(self, *filters:
|
|
90
|
+
def _merge_and(self, *filters: dict[str, Any] | None) -> dict[str, Any]:
|
|
88
91
|
parts = [f for f in filters if f]
|
|
89
92
|
if not parts:
|
|
90
93
|
return {}
|
|
@@ -104,7 +107,7 @@ class NoSqlRepository:
|
|
|
104
107
|
return val
|
|
105
108
|
|
|
106
109
|
@staticmethod
|
|
107
|
-
def _public_doc(doc:
|
|
110
|
+
def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
|
|
108
111
|
d = dict(doc)
|
|
109
112
|
if "_id" in d and "id" not in d:
|
|
110
113
|
_id = d.pop("_id", None)
|
|
@@ -120,20 +123,20 @@ class NoSqlRepository:
|
|
|
120
123
|
*,
|
|
121
124
|
limit: int,
|
|
122
125
|
offset: int,
|
|
123
|
-
sort:
|
|
124
|
-
filter:
|
|
125
|
-
) ->
|
|
126
|
+
sort: builtins.list[tuple[str, int]] | None = None,
|
|
127
|
+
filter: dict[str, Any] | None = None,
|
|
128
|
+
) -> builtins.list[dict[str, Any]]:
|
|
126
129
|
filt = self._merge_and(self._alive_filter(), filter)
|
|
127
130
|
cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
|
|
128
131
|
if sort:
|
|
129
132
|
cursor = cursor.sort(sort)
|
|
130
133
|
return [self._public_doc(doc) async for doc in cursor]
|
|
131
134
|
|
|
132
|
-
async def count(self, db, *, filter:
|
|
135
|
+
async def count(self, db, *, filter: dict[str, Any] | None = None) -> int:
|
|
133
136
|
filt = self._merge_and(self._alive_filter(), filter)
|
|
134
|
-
return cast(int, await db[self.collection_name].count_documents(filt or {}))
|
|
137
|
+
return cast("int", await db[self.collection_name].count_documents(filt or {}))
|
|
135
138
|
|
|
136
|
-
async def get(self, db, id_value: Any) ->
|
|
139
|
+
async def get(self, db, id_value: Any) -> dict | None:
|
|
137
140
|
id_value = self._normalize_id_value(id_value)
|
|
138
141
|
filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
|
|
139
142
|
doc = await db[self.collection_name].find_one(filt)
|
|
@@ -141,7 +144,7 @@ class NoSqlRepository:
|
|
|
141
144
|
return None
|
|
142
145
|
return self._public_doc(doc)
|
|
143
146
|
|
|
144
|
-
async def create(self, db, data:
|
|
147
|
+
async def create(self, db, data: dict[str, Any]) -> dict[str, Any]:
|
|
145
148
|
# don't let clients supply soft-delete artifacts on create
|
|
146
149
|
if self.soft_delete:
|
|
147
150
|
data.pop(self.soft_delete_field, None)
|
|
@@ -150,7 +153,7 @@ class NoSqlRepository:
|
|
|
150
153
|
res = await db[self.collection_name].insert_one(data)
|
|
151
154
|
return self._public_doc({**data, "_id": res.inserted_id})
|
|
152
155
|
|
|
153
|
-
async def update(self, db, id_value: Any, data:
|
|
156
|
+
async def update(self, db, id_value: Any, data: dict[str, Any]) -> dict | None:
|
|
154
157
|
for k in list(data.keys()):
|
|
155
158
|
if k in self.immutable_fields:
|
|
156
159
|
data.pop(k, None)
|
|
@@ -162,19 +165,19 @@ class NoSqlRepository:
|
|
|
162
165
|
async def delete(self, db, id_value: Any) -> bool:
|
|
163
166
|
id_value = self._normalize_id_value(id_value)
|
|
164
167
|
if self.soft_delete:
|
|
165
|
-
set_ops:
|
|
168
|
+
set_ops: dict[str, Any] = {}
|
|
166
169
|
if self.soft_delete_flag_field:
|
|
167
170
|
set_ops[self.soft_delete_flag_field] = False
|
|
168
|
-
from datetime import datetime
|
|
171
|
+
from datetime import datetime
|
|
169
172
|
|
|
170
|
-
set_ops[self.soft_delete_field] = datetime.now(
|
|
173
|
+
set_ops[self.soft_delete_field] = datetime.now(UTC)
|
|
171
174
|
res = await db[self.collection_name].update_one(
|
|
172
175
|
{self.id_field: id_value}, {"$set": set_ops}
|
|
173
176
|
)
|
|
174
|
-
return cast(int, res.modified_count) > 0
|
|
177
|
+
return cast("int", res.modified_count) > 0
|
|
175
178
|
|
|
176
179
|
res = await db[self.collection_name].delete_one({self.id_field: id_value})
|
|
177
|
-
return cast(int, res.deleted_count) > 0
|
|
180
|
+
return cast("int", res.deleted_count) > 0
|
|
178
181
|
|
|
179
182
|
async def search(
|
|
180
183
|
self,
|
|
@@ -184,14 +187,12 @@ class NoSqlRepository:
|
|
|
184
187
|
fields: Sequence[str],
|
|
185
188
|
limit: int,
|
|
186
189
|
offset: int,
|
|
187
|
-
sort:
|
|
188
|
-
) ->
|
|
190
|
+
sort: builtins.list[tuple[str, int]] | None = None,
|
|
191
|
+
) -> builtins.list[dict[str, Any]]:
|
|
189
192
|
regex = {"$regex": q, "$options": "i"}
|
|
190
193
|
or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
|
|
191
194
|
filt = (
|
|
192
|
-
self._merge_and(self._alive_filter(), *or_filter)
|
|
193
|
-
if or_filter
|
|
194
|
-
else self._alive_filter()
|
|
195
|
+
self._merge_and(self._alive_filter(), *or_filter) if or_filter else self._alive_filter()
|
|
195
196
|
)
|
|
196
197
|
cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
|
|
197
198
|
if sort:
|
|
@@ -202,11 +203,9 @@ class NoSqlRepository:
|
|
|
202
203
|
regex = {"$regex": q, "$options": "i"}
|
|
203
204
|
or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
|
|
204
205
|
filt = self._merge_and(self._alive_filter(), or_filter)
|
|
205
|
-
return cast(int, await db[self.collection_name].count_documents(filt or {}))
|
|
206
|
+
return cast("int", await db[self.collection_name].count_documents(filt or {}))
|
|
206
207
|
|
|
207
|
-
async def exists(self, db, *, where: Iterable[
|
|
208
|
+
async def exists(self, db, *, where: Iterable[dict[str, Any]]) -> bool:
|
|
208
209
|
filt = self._merge_and(self._alive_filter(), *list(where))
|
|
209
|
-
doc = await db[self.collection_name].find_one(
|
|
210
|
-
filt, projection={self.id_field: 1}
|
|
211
|
-
)
|
|
210
|
+
doc = await db[self.collection_name].find_one(filt, projection={self.id_field: 1})
|
|
212
211
|
return doc is not None
|
svc_infra/db/nosql/resource.py
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import (
|
|
5
|
-
Any,
|
|
6
|
-
Callable,
|
|
7
|
-
Iterable,
|
|
8
|
-
Optional,
|
|
9
|
-
Sequence,
|
|
10
6
|
TYPE_CHECKING,
|
|
11
|
-
|
|
12
|
-
Union,
|
|
7
|
+
Any,
|
|
13
8
|
)
|
|
14
9
|
|
|
15
10
|
if TYPE_CHECKING:
|
|
@@ -56,36 +51,36 @@ class NoSqlResource:
|
|
|
56
51
|
"""
|
|
57
52
|
|
|
58
53
|
# API mounting
|
|
59
|
-
collection:
|
|
54
|
+
collection: str | None = None
|
|
60
55
|
prefix: str = ""
|
|
61
|
-
document_model:
|
|
56
|
+
document_model: type[Any] | None = None
|
|
62
57
|
|
|
63
58
|
# optional Pydantic schemas (auto-derived if omitted)
|
|
64
|
-
read_schema:
|
|
65
|
-
create_schema:
|
|
66
|
-
update_schema:
|
|
59
|
+
read_schema: type[Any] | None = None
|
|
60
|
+
create_schema: type[Any] | None = None
|
|
61
|
+
update_schema: type[Any] | None = None
|
|
67
62
|
|
|
68
63
|
# behavior
|
|
69
|
-
search_fields:
|
|
70
|
-
tags:
|
|
64
|
+
search_fields: Sequence[str] | None = None
|
|
65
|
+
tags: list[str] | None = None
|
|
71
66
|
id_field: str = "_id"
|
|
72
67
|
soft_delete: bool = False
|
|
73
68
|
soft_delete_field: str = "deleted_at"
|
|
74
|
-
soft_delete_flag_field:
|
|
69
|
+
soft_delete_flag_field: str | None = None
|
|
75
70
|
|
|
76
71
|
# custom wiring
|
|
77
|
-
service_factory:
|
|
72
|
+
service_factory: Callable[[Any], Any] | None = None
|
|
78
73
|
|
|
79
74
|
# generated schema naming and exclusions
|
|
80
|
-
read_name:
|
|
81
|
-
create_name:
|
|
82
|
-
update_name:
|
|
75
|
+
read_name: str | None = None
|
|
76
|
+
create_name: str | None = None
|
|
77
|
+
update_name: str | None = None
|
|
83
78
|
create_exclude: tuple[str, ...] = ("_id",)
|
|
84
79
|
read_exclude: tuple[str, ...] = ()
|
|
85
80
|
update_exclude: tuple[str, ...] = ()
|
|
86
81
|
|
|
87
82
|
# NEW: indexes defined per collection (normalized to IndexModel at prepare time)
|
|
88
|
-
indexes:
|
|
83
|
+
indexes: Iterable[IndexModel | IndexAlias] | None = None
|
|
89
84
|
|
|
90
85
|
def __post_init__(self):
|
|
91
86
|
if not self.collection and self.document_model:
|
svc_infra/db/nosql/scaffold.py
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any,
|
|
4
|
+
from typing import Any, Literal
|
|
5
5
|
|
|
6
6
|
from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
|
|
7
7
|
from svc_infra.utils import ensure_init_py, render_template, write
|
|
8
8
|
|
|
9
|
-
_INIT_CONTENT_PAIRED =
|
|
10
|
-
'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
|
|
11
|
-
)
|
|
9
|
+
_INIT_CONTENT_PAIRED = 'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
|
|
12
10
|
_INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
|
|
13
11
|
|
|
14
12
|
|
|
15
|
-
def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) ->
|
|
13
|
+
def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> dict[str, Any]:
|
|
16
14
|
content = _INIT_CONTENT_PAIRED if paired else _INIT_CONTENT_MINIMAL
|
|
17
15
|
return ensure_init_py(dir_path, overwrite, paired, content)
|
|
18
16
|
|
|
@@ -29,9 +27,9 @@ def scaffold_core(
|
|
|
29
27
|
entity_name: str = "Item",
|
|
30
28
|
overwrite: bool = False,
|
|
31
29
|
same_dir: bool = False,
|
|
32
|
-
documents_filename:
|
|
33
|
-
schemas_filename:
|
|
34
|
-
) ->
|
|
30
|
+
documents_filename: str | None = None,
|
|
31
|
+
schemas_filename: str | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
35
33
|
"""Create starter Mongo document model + CRUD schemas."""
|
|
36
34
|
|
|
37
35
|
documents_dir = normalize_dir(documents_dir)
|
|
@@ -48,9 +46,7 @@ def scaffold_core(
|
|
|
48
46
|
schemas_txt = render_template(
|
|
49
47
|
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
50
48
|
name="schemas.py.tmpl",
|
|
51
|
-
subs={
|
|
52
|
-
"Entity": ent
|
|
53
|
-
}, # (only if your schemas.tmpl doesn't need collection_name)
|
|
49
|
+
subs={"Entity": ent}, # (only if your schemas.tmpl doesn't need collection_name)
|
|
54
50
|
)
|
|
55
51
|
|
|
56
52
|
if same_dir:
|
|
@@ -80,8 +76,8 @@ def scaffold_documents_core(
|
|
|
80
76
|
dest_dir: Path | str,
|
|
81
77
|
entity_name: str = "Item",
|
|
82
78
|
overwrite: bool = False,
|
|
83
|
-
documents_filename:
|
|
84
|
-
) ->
|
|
79
|
+
documents_filename: str | None = None,
|
|
80
|
+
) -> dict[str, Any]:
|
|
85
81
|
dest = normalize_dir(dest_dir)
|
|
86
82
|
ent = pascal(entity_name)
|
|
87
83
|
coll = plural_snake(ent)
|
|
@@ -102,8 +98,8 @@ def scaffold_schemas_core(
|
|
|
102
98
|
dest_dir: Path | str,
|
|
103
99
|
entity_name: str = "Item",
|
|
104
100
|
overwrite: bool = False,
|
|
105
|
-
schemas_filename:
|
|
106
|
-
) ->
|
|
101
|
+
schemas_filename: str | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
107
103
|
dest = normalize_dir(dest_dir)
|
|
108
104
|
ent = pascal(entity_name)
|
|
109
105
|
txt = render_template(
|
|
@@ -121,9 +117,9 @@ def scaffold_resources_core(
|
|
|
121
117
|
*,
|
|
122
118
|
dest_dir: Path | str,
|
|
123
119
|
entity_name: str = "Item",
|
|
124
|
-
filename:
|
|
120
|
+
filename: str | None = None, # defaults to "resources.py"
|
|
125
121
|
overwrite: bool = False,
|
|
126
|
-
) ->
|
|
122
|
+
) -> dict[str, Any]:
|
|
127
123
|
"""
|
|
128
124
|
Create a starter resources.py with:
|
|
129
125
|
- empty RESOURCES list
|
svc_infra/db/nosql/service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from .repository import NoSqlRepository
|
|
6
7
|
|
|
@@ -43,9 +44,7 @@ class NoSqlService:
|
|
|
43
44
|
async def search(
|
|
44
45
|
self, db, *, q: str, fields: Sequence[str], limit: int, offset: int, sort=None
|
|
45
46
|
):
|
|
46
|
-
return await self.repo.search(
|
|
47
|
-
db, q=q, fields=fields, limit=limit, offset=offset, sort=sort
|
|
48
|
-
)
|
|
47
|
+
return await self.repo.search(db, q=q, fields=fields, limit=limit, offset=offset, sort=sort)
|
|
49
48
|
|
|
50
49
|
async def count_filtered(self, db, *, q: str, fields: Sequence[str]) -> int:
|
|
51
50
|
return await self.repo.count_filtered(db, q=q, fields=fields)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from .service import NoSqlService
|
|
6
7
|
|
|
@@ -11,8 +12,8 @@ class NoSqlServiceWithHooks(NoSqlService):
|
|
|
11
12
|
def __init__(
|
|
12
13
|
self,
|
|
13
14
|
repo,
|
|
14
|
-
pre_create:
|
|
15
|
-
pre_update:
|
|
15
|
+
pre_create: PreHook | None = None,
|
|
16
|
+
pre_update: PreHook | None = None,
|
|
16
17
|
):
|
|
17
18
|
super().__init__(repo)
|
|
18
19
|
self._pre_create = pre_create
|
svc_infra/db/nosql/types.py
CHANGED
|
@@ -11,9 +11,7 @@ class PyObjectId(ObjectId):
|
|
|
11
11
|
"""Pydantic v2-compatible ObjectId type."""
|
|
12
12
|
|
|
13
13
|
@classmethod
|
|
14
|
-
def __get_pydantic_core_schema__(
|
|
15
|
-
cls, _source_type: Any, _handler: GetCoreSchemaHandler
|
|
16
|
-
):
|
|
14
|
+
def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: GetCoreSchemaHandler):
|
|
17
15
|
def validate(v: Any) -> ObjectId:
|
|
18
16
|
if isinstance(v, ObjectId):
|
|
19
17
|
return v
|
|
@@ -24,6 +22,4 @@ class PyObjectId(ObjectId):
|
|
|
24
22
|
raise ValueError(f"Invalid ObjectId: {v}") from e
|
|
25
23
|
raise ValueError("ObjectId required")
|
|
26
24
|
|
|
27
|
-
return core_schema.no_info_after_validator_function(
|
|
28
|
-
validate, core_schema.any_schema()
|
|
29
|
-
)
|
|
25
|
+
return core_schema.no_info_after_validator_function(validate, core_schema.any_schema())
|
svc_infra/db/nosql/utils.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
4
5
|
from pathlib import Path
|
|
5
|
-
from typing import Optional, Sequence
|
|
6
6
|
|
|
7
7
|
from dotenv import load_dotenv
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ def prepare_process_env(project_root: Path | str) -> Path:
|
|
|
27
27
|
return root
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def _read_secret_from_file(path: str) ->
|
|
30
|
+
def _read_secret_from_file(path: str) -> str | None:
|
|
31
31
|
try:
|
|
32
32
|
p = Path(path)
|
|
33
33
|
if p.exists():
|
|
@@ -40,7 +40,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
|
|
|
40
40
|
def get_mongo_url_from_env(
|
|
41
41
|
required: bool = True,
|
|
42
42
|
env_vars: Sequence[str] = DEFAULT_MONGO_ENV_VARS,
|
|
43
|
-
) ->
|
|
43
|
+
) -> str | None:
|
|
44
44
|
"""
|
|
45
45
|
Resolve the Mongo connection string with support for:
|
|
46
46
|
- Primary env vars (DEFAULT_MONGO_ENV_VARS).
|
|
@@ -99,7 +99,7 @@ def get_mongo_dbname_from_env(
|
|
|
99
99
|
required: bool = False,
|
|
100
100
|
env_vars: Sequence[str] = DEFAULT_MONGO_DB_ENV_VARS,
|
|
101
101
|
default: str = "app",
|
|
102
|
-
) ->
|
|
102
|
+
) -> str | None:
|
|
103
103
|
"""Return a database name from env; optional (Motor can connect without it)."""
|
|
104
104
|
load_dotenv(override=False)
|
|
105
105
|
for key in env_vars:
|
svc_infra/db/ops.py
CHANGED
|
@@ -25,7 +25,8 @@ from __future__ import annotations
|
|
|
25
25
|
import logging
|
|
26
26
|
import sys
|
|
27
27
|
import time
|
|
28
|
-
from
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from typing import Any, cast
|
|
29
30
|
|
|
30
31
|
from .sql.utils import get_database_url_from_env
|
|
31
32
|
|
|
@@ -45,7 +46,7 @@ def _flush() -> None:
|
|
|
45
46
|
sys.stderr.flush()
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
def _get_connection(url:
|
|
49
|
+
def _get_connection(url: str | None = None, connect_timeout: int = 10) -> Any:
|
|
49
50
|
"""
|
|
50
51
|
Get a psycopg2 connection.
|
|
51
52
|
|
|
@@ -64,8 +65,7 @@ def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any
|
|
|
64
65
|
import psycopg2
|
|
65
66
|
except ImportError as e:
|
|
66
67
|
raise ImportError(
|
|
67
|
-
"psycopg2 is required for db.ops utilities. "
|
|
68
|
-
"Install with: pip install psycopg2-binary"
|
|
68
|
+
"psycopg2 is required for db.ops utilities. Install with: pip install psycopg2-binary"
|
|
69
69
|
) from e
|
|
70
70
|
|
|
71
71
|
if url is None:
|
|
@@ -76,7 +76,7 @@ def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any
|
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def wait_for_database(
|
|
79
|
-
url:
|
|
79
|
+
url: str | None = None,
|
|
80
80
|
timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
81
81
|
interval: float = DEFAULT_WAIT_INTERVAL,
|
|
82
82
|
verbose: bool = True,
|
|
@@ -114,9 +114,7 @@ def wait_for_database(
|
|
|
114
114
|
|
|
115
115
|
if elapsed >= timeout:
|
|
116
116
|
if verbose:
|
|
117
|
-
logger.error(
|
|
118
|
-
f"Database not ready after {timeout}s ({attempt} attempts)"
|
|
119
|
-
)
|
|
117
|
+
logger.error(f"Database not ready after {timeout}s ({attempt} attempts)")
|
|
120
118
|
_flush()
|
|
121
119
|
return False
|
|
122
120
|
|
|
@@ -130,20 +128,18 @@ def wait_for_database(
|
|
|
130
128
|
except Exception as e:
|
|
131
129
|
if verbose:
|
|
132
130
|
remaining = timeout - elapsed
|
|
133
|
-
logger.debug(
|
|
134
|
-
f"Database not ready ({e}), retrying... ({remaining:.0f}s remaining)"
|
|
135
|
-
)
|
|
131
|
+
logger.debug(f"Database not ready ({e}), retrying... ({remaining:.0f}s remaining)")
|
|
136
132
|
_flush()
|
|
137
133
|
time.sleep(interval)
|
|
138
134
|
|
|
139
135
|
|
|
140
136
|
def run_sync_sql(
|
|
141
137
|
sql: str,
|
|
142
|
-
params:
|
|
143
|
-
url:
|
|
138
|
+
params: Sequence[Any] | None = None,
|
|
139
|
+
url: str | None = None,
|
|
144
140
|
timeout: int = DEFAULT_STATEMENT_TIMEOUT,
|
|
145
141
|
fetch: bool = False,
|
|
146
|
-
) ->
|
|
142
|
+
) -> list[tuple[Any, ...]] | None:
|
|
147
143
|
"""
|
|
148
144
|
Execute SQL synchronously with a statement timeout.
|
|
149
145
|
|
|
@@ -188,7 +184,7 @@ def run_sync_sql(
|
|
|
188
184
|
cur.execute(sql)
|
|
189
185
|
|
|
190
186
|
if fetch:
|
|
191
|
-
return cast(list[tuple[Any, ...]], cur.fetchall())
|
|
187
|
+
return cast("list[tuple[Any, ...]]", cur.fetchall())
|
|
192
188
|
|
|
193
189
|
conn.commit()
|
|
194
190
|
return None
|
|
@@ -198,7 +194,7 @@ def run_sync_sql(
|
|
|
198
194
|
|
|
199
195
|
def kill_blocking_queries(
|
|
200
196
|
table_name: str,
|
|
201
|
-
url:
|
|
197
|
+
url: str | None = None,
|
|
202
198
|
timeout: int = DEFAULT_STATEMENT_TIMEOUT,
|
|
203
199
|
dry_run: bool = False,
|
|
204
200
|
) -> list[dict[str, Any]]:
|
|
@@ -286,7 +282,7 @@ def kill_blocking_queries(
|
|
|
286
282
|
|
|
287
283
|
def drop_table_safe(
|
|
288
284
|
table_name: str,
|
|
289
|
-
url:
|
|
285
|
+
url: str | None = None,
|
|
290
286
|
timeout: int = DEFAULT_STATEMENT_TIMEOUT,
|
|
291
287
|
kill_blocking: bool = True,
|
|
292
288
|
if_exists: bool = True,
|
|
@@ -353,7 +349,7 @@ def drop_table_safe(
|
|
|
353
349
|
def get_database_url(
|
|
354
350
|
required: bool = True,
|
|
355
351
|
normalize: bool = True,
|
|
356
|
-
) ->
|
|
352
|
+
) -> str | None:
|
|
357
353
|
"""
|
|
358
354
|
Convenience wrapper for get_database_url_from_env().
|
|
359
355
|
|
svc_infra/db/outbox.py
CHANGED
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Iterable
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
|
-
from datetime import
|
|
5
|
-
from typing import Any,
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, Protocol
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
@dataclass
|
|
9
10
|
class OutboxMessage:
|
|
10
11
|
id: int
|
|
11
12
|
topic: str
|
|
12
|
-
payload:
|
|
13
|
-
created_at: datetime = field(default_factory=lambda: datetime.now(
|
|
13
|
+
payload: dict[str, Any]
|
|
14
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
14
15
|
attempts: int = 0
|
|
15
|
-
processed_at:
|
|
16
|
+
processed_at: datetime | None = None
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class OutboxStore(Protocol):
|
|
19
|
-
def enqueue(self, topic: str, payload:
|
|
20
|
+
def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
|
|
20
21
|
pass
|
|
21
22
|
|
|
22
|
-
def fetch_next(
|
|
23
|
-
self, *, topics: Optional[Iterable[str]] = None
|
|
24
|
-
) -> Optional[OutboxMessage]:
|
|
23
|
+
def fetch_next(self, *, topics: Iterable[str] | None = None) -> OutboxMessage | None:
|
|
25
24
|
"""Return the next undispatched, unprocessed message (FIFO per-topic), or None.
|
|
26
25
|
|
|
27
26
|
Notes:
|
|
@@ -42,17 +41,15 @@ class InMemoryOutboxStore:
|
|
|
42
41
|
|
|
43
42
|
def __init__(self):
|
|
44
43
|
self._seq = 0
|
|
45
|
-
self._messages:
|
|
44
|
+
self._messages: list[OutboxMessage] = []
|
|
46
45
|
|
|
47
|
-
def enqueue(self, topic: str, payload:
|
|
46
|
+
def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
|
|
48
47
|
self._seq += 1
|
|
49
48
|
msg = OutboxMessage(id=self._seq, topic=topic, payload=dict(payload))
|
|
50
49
|
self._messages.append(msg)
|
|
51
50
|
return msg
|
|
52
51
|
|
|
53
|
-
def fetch_next(
|
|
54
|
-
self, *, topics: Optional[Iterable[str]] = None
|
|
55
|
-
) -> Optional[OutboxMessage]:
|
|
52
|
+
def fetch_next(self, *, topics: Iterable[str] | None = None) -> OutboxMessage | None:
|
|
56
53
|
allowed = set(topics) if topics else None
|
|
57
54
|
for msg in self._messages:
|
|
58
55
|
if msg.processed_at is not None:
|
|
@@ -68,7 +65,7 @@ class InMemoryOutboxStore:
|
|
|
68
65
|
def mark_processed(self, msg_id: int) -> None:
|
|
69
66
|
for msg in self._messages:
|
|
70
67
|
if msg.id == msg_id:
|
|
71
|
-
msg.processed_at = datetime.now(
|
|
68
|
+
msg.processed_at = datetime.now(UTC)
|
|
72
69
|
return
|
|
73
70
|
|
|
74
71
|
def mark_failed(self, msg_id: int) -> None:
|
|
@@ -92,13 +89,13 @@ class SqlOutboxStore:
|
|
|
92
89
|
|
|
93
90
|
# Placeholders to outline the API; not implemented here.
|
|
94
91
|
def enqueue(
|
|
95
|
-
self, topic: str, payload:
|
|
92
|
+
self, topic: str, payload: dict[str, Any]
|
|
96
93
|
) -> OutboxMessage: # pragma: no cover - skeleton
|
|
97
94
|
raise NotImplementedError
|
|
98
95
|
|
|
99
96
|
def fetch_next(
|
|
100
|
-
self, *, topics:
|
|
101
|
-
) ->
|
|
97
|
+
self, *, topics: Iterable[str] | None = None
|
|
98
|
+
) -> OutboxMessage | None: # pragma: no cover - skeleton
|
|
102
99
|
raise NotImplementedError
|
|
103
100
|
|
|
104
101
|
def mark_processed(self, msg_id: int) -> None: # pragma: no cover - skeleton
|