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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/db/nosql/repository.py
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
3
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
from bson import ObjectId
|
|
7
|
+
|
|
8
|
+
_HAS_BSON = True
|
|
9
|
+
except ModuleNotFoundError:
|
|
10
|
+
# `bson` is provided by the optional `pymongo` dependency.
|
|
11
|
+
# Keep imports working for non-mongo users/tests; runtime Mongo usage still
|
|
12
|
+
# requires installing pymongo.
|
|
13
|
+
_HAS_BSON = False
|
|
14
|
+
|
|
15
|
+
class ObjectId: # type: ignore[no-redef]
|
|
16
|
+
pass
|
|
6
17
|
|
|
7
18
|
|
|
8
19
|
class NoSqlRepository:
|
|
@@ -78,11 +89,13 @@ class NoSqlRepository:
|
|
|
78
89
|
if not parts:
|
|
79
90
|
return {}
|
|
80
91
|
if len(parts) == 1:
|
|
81
|
-
return parts[0]
|
|
92
|
+
return parts[0]
|
|
82
93
|
return {"$and": parts}
|
|
83
94
|
|
|
84
95
|
def _normalize_id_value(self, val: Any) -> Any:
|
|
85
96
|
"""If we use Mongo’s _id and a string is passed, coerce to ObjectId when possible."""
|
|
97
|
+
if not _HAS_BSON:
|
|
98
|
+
return val
|
|
86
99
|
if self.id_field == "_id" and isinstance(val, str):
|
|
87
100
|
try:
|
|
88
101
|
return ObjectId(val)
|
|
@@ -91,13 +104,14 @@ class NoSqlRepository:
|
|
|
91
104
|
return val
|
|
92
105
|
|
|
93
106
|
@staticmethod
|
|
94
|
-
def _public_doc(doc:
|
|
95
|
-
if not doc:
|
|
96
|
-
return doc
|
|
107
|
+
def _public_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
|
|
97
108
|
d = dict(doc)
|
|
98
109
|
if "_id" in d and "id" not in d:
|
|
99
110
|
_id = d.pop("_id", None)
|
|
100
|
-
|
|
111
|
+
if _HAS_BSON and isinstance(_id, ObjectId):
|
|
112
|
+
d["id"] = str(_id)
|
|
113
|
+
else:
|
|
114
|
+
d["id"] = _id
|
|
101
115
|
return d
|
|
102
116
|
|
|
103
117
|
async def list(
|
|
@@ -108,7 +122,7 @@ class NoSqlRepository:
|
|
|
108
122
|
offset: int,
|
|
109
123
|
sort: Optional[List[Tuple[str, int]]] = None,
|
|
110
124
|
filter: Optional[Dict[str, Any]] = None,
|
|
111
|
-
) -> List[Dict]:
|
|
125
|
+
) -> List[Dict[str, Any]]:
|
|
112
126
|
filt = self._merge_and(self._alive_filter(), filter)
|
|
113
127
|
cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
|
|
114
128
|
if sort:
|
|
@@ -117,12 +131,14 @@ class NoSqlRepository:
|
|
|
117
131
|
|
|
118
132
|
async def count(self, db, *, filter: Optional[Dict[str, Any]] = None) -> int:
|
|
119
133
|
filt = self._merge_and(self._alive_filter(), filter)
|
|
120
|
-
return await db[self.collection_name].count_documents(filt or {})
|
|
134
|
+
return cast(int, await db[self.collection_name].count_documents(filt or {}))
|
|
121
135
|
|
|
122
136
|
async def get(self, db, id_value: Any) -> Dict | None:
|
|
123
137
|
id_value = self._normalize_id_value(id_value)
|
|
124
138
|
filt = self._merge_and(self._alive_filter(), {self.id_field: id_value})
|
|
125
139
|
doc = await db[self.collection_name].find_one(filt)
|
|
140
|
+
if doc is None:
|
|
141
|
+
return None
|
|
126
142
|
return self._public_doc(doc)
|
|
127
143
|
|
|
128
144
|
async def create(self, db, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -155,10 +171,10 @@ class NoSqlRepository:
|
|
|
155
171
|
res = await db[self.collection_name].update_one(
|
|
156
172
|
{self.id_field: id_value}, {"$set": set_ops}
|
|
157
173
|
)
|
|
158
|
-
return res.modified_count > 0
|
|
174
|
+
return cast(int, res.modified_count) > 0
|
|
159
175
|
|
|
160
176
|
res = await db[self.collection_name].delete_one({self.id_field: id_value})
|
|
161
|
-
return res.deleted_count > 0
|
|
177
|
+
return cast(int, res.deleted_count) > 0
|
|
162
178
|
|
|
163
179
|
async def search(
|
|
164
180
|
self,
|
|
@@ -169,11 +185,13 @@ class NoSqlRepository:
|
|
|
169
185
|
limit: int,
|
|
170
186
|
offset: int,
|
|
171
187
|
sort: Optional[List[Tuple[str, int]]] = None,
|
|
172
|
-
) -> List[Dict]:
|
|
188
|
+
) -> List[Dict[str, Any]]:
|
|
173
189
|
regex = {"$regex": q, "$options": "i"}
|
|
174
190
|
or_filter = [{"$or": [{f: regex} for f in fields]}] if fields else []
|
|
175
191
|
filt = (
|
|
176
|
-
self._merge_and(self._alive_filter(), *or_filter)
|
|
192
|
+
self._merge_and(self._alive_filter(), *or_filter)
|
|
193
|
+
if or_filter
|
|
194
|
+
else self._alive_filter()
|
|
177
195
|
)
|
|
178
196
|
cursor = db[self.collection_name].find(filt).skip(offset).limit(limit)
|
|
179
197
|
if sort:
|
|
@@ -184,9 +202,11 @@ class NoSqlRepository:
|
|
|
184
202
|
regex = {"$regex": q, "$options": "i"}
|
|
185
203
|
or_filter = {"$or": [{f: regex} for f in fields]} if fields else {}
|
|
186
204
|
filt = self._merge_and(self._alive_filter(), or_filter)
|
|
187
|
-
return await db[self.collection_name].count_documents(filt or {})
|
|
205
|
+
return cast(int, await db[self.collection_name].count_documents(filt or {}))
|
|
188
206
|
|
|
189
207
|
async def exists(self, db, *, where: Iterable[Dict[str, Any]]) -> bool:
|
|
190
208
|
filt = self._merge_and(self._alive_filter(), *list(where))
|
|
191
|
-
doc = await db[self.collection_name].find_one(
|
|
209
|
+
doc = await db[self.collection_name].find_one(
|
|
210
|
+
filt, projection={self.id_field: 1}
|
|
211
|
+
)
|
|
192
212
|
return doc is not None
|
svc_infra/db/nosql/resource.py
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
Callable,
|
|
7
|
+
Iterable,
|
|
8
|
+
Optional,
|
|
9
|
+
Sequence,
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Type,
|
|
12
|
+
Union,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pymongo import IndexModel
|
|
17
|
+
else:
|
|
18
|
+
try:
|
|
19
|
+
from pymongo import IndexModel
|
|
20
|
+
except ModuleNotFoundError:
|
|
21
|
+
# Minimal runtime stub so importing svc_infra works without optional Mongo deps.
|
|
22
|
+
class IndexModel: # type: ignore[no-redef]
|
|
23
|
+
pass
|
|
7
24
|
|
|
8
25
|
|
|
9
26
|
def _snake(name: str) -> str:
|
svc_infra/db/nosql/scaffold.py
CHANGED
|
@@ -6,7 +6,9 @@ from typing import Any, Dict, Literal, Optional
|
|
|
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 =
|
|
9
|
+
_INIT_CONTENT_PAIRED = (
|
|
10
|
+
'from . import documents, schemas\n\n__all__ = ["documents", "schemas"]\n'
|
|
11
|
+
)
|
|
10
12
|
_INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
|
|
11
13
|
|
|
12
14
|
|
|
@@ -46,7 +48,9 @@ def scaffold_core(
|
|
|
46
48
|
schemas_txt = render_template(
|
|
47
49
|
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
48
50
|
name="schemas.py.tmpl",
|
|
49
|
-
subs={
|
|
51
|
+
subs={
|
|
52
|
+
"Entity": ent
|
|
53
|
+
}, # (only if your schemas.tmpl doesn't need collection_name)
|
|
50
54
|
)
|
|
51
55
|
|
|
52
56
|
if same_dir:
|
|
@@ -103,7 +107,9 @@ def scaffold_schemas_core(
|
|
|
103
107
|
dest = normalize_dir(dest_dir)
|
|
104
108
|
ent = pascal(entity_name)
|
|
105
109
|
txt = render_template(
|
|
106
|
-
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
110
|
+
tmpl_dir="svc_infra.db.nosql.mongo.templates",
|
|
111
|
+
name="schemas.py.tmpl",
|
|
112
|
+
subs={"Entity": ent},
|
|
107
113
|
)
|
|
108
114
|
filename = schemas_filename or f"{snake(entity_name)}.py"
|
|
109
115
|
res = write(dest / filename, txt, overwrite)
|
svc_infra/db/nosql/service.py
CHANGED
|
@@ -43,7 +43,9 @@ class NoSqlService:
|
|
|
43
43
|
async def search(
|
|
44
44
|
self, db, *, q: str, fields: Sequence[str], limit: int, offset: int, sort=None
|
|
45
45
|
):
|
|
46
|
-
return await self.repo.search(
|
|
46
|
+
return await self.repo.search(
|
|
47
|
+
db, q=q, fields=fields, limit=limit, offset=offset, sort=sort
|
|
48
|
+
)
|
|
47
49
|
|
|
48
50
|
async def count_filtered(self, db, *, q: str, fields: Sequence[str]) -> int:
|
|
49
51
|
return await self.repo.count_filtered(db, q=q, fields=fields)
|
svc_infra/db/nosql/types.py
CHANGED
|
@@ -11,7 +11,9 @@ class PyObjectId(ObjectId):
|
|
|
11
11
|
"""Pydantic v2-compatible ObjectId type."""
|
|
12
12
|
|
|
13
13
|
@classmethod
|
|
14
|
-
def __get_pydantic_core_schema__(
|
|
14
|
+
def __get_pydantic_core_schema__(
|
|
15
|
+
cls, _source_type: Any, _handler: GetCoreSchemaHandler
|
|
16
|
+
):
|
|
15
17
|
def validate(v: Any) -> ObjectId:
|
|
16
18
|
if isinstance(v, ObjectId):
|
|
17
19
|
return v
|
|
@@ -22,4 +24,6 @@ class PyObjectId(ObjectId):
|
|
|
22
24
|
raise ValueError(f"Invalid ObjectId: {v}") from e
|
|
23
25
|
raise ValueError("ObjectId required")
|
|
24
26
|
|
|
25
|
-
return core_schema.no_info_after_validator_function(
|
|
27
|
+
return core_schema.no_info_after_validator_function(
|
|
28
|
+
validate, core_schema.any_schema()
|
|
29
|
+
)
|
svc_infra/db/ops.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Database operations utilities for one-off administrative tasks.
|
|
2
|
+
|
|
3
|
+
This module provides synchronous database utilities for operations that
|
|
4
|
+
don't fit the normal async SQLAlchemy workflow, such as:
|
|
5
|
+
- Waiting for database readiness at startup
|
|
6
|
+
- Executing maintenance SQL
|
|
7
|
+
- Dropping tables with lock handling
|
|
8
|
+
- Terminating blocking queries
|
|
9
|
+
|
|
10
|
+
These utilities use psycopg2 directly for maximum reliability in
|
|
11
|
+
edge cases where the ORM might not be available or appropriate.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from svc_infra.db.ops import wait_for_database, run_sync_sql
|
|
15
|
+
>>>
|
|
16
|
+
>>> # Wait for database before app starts
|
|
17
|
+
>>> wait_for_database(timeout=30)
|
|
18
|
+
>>>
|
|
19
|
+
>>> # Run maintenance query
|
|
20
|
+
>>> run_sync_sql("VACUUM ANALYZE my_table")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Optional, Sequence, cast
|
|
29
|
+
|
|
30
|
+
from .sql.utils import get_database_url_from_env
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Timeout for individual database operations (seconds)
|
|
35
|
+
DEFAULT_STATEMENT_TIMEOUT = 30
|
|
36
|
+
|
|
37
|
+
# Default wait-for-database settings
|
|
38
|
+
DEFAULT_WAIT_TIMEOUT = 30
|
|
39
|
+
DEFAULT_WAIT_INTERVAL = 1.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _flush() -> None:
|
|
43
|
+
"""Force flush stdout/stderr for containerized log visibility."""
|
|
44
|
+
sys.stdout.flush()
|
|
45
|
+
sys.stderr.flush()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_connection(url: Optional[str] = None, connect_timeout: int = 10) -> Any:
|
|
49
|
+
"""
|
|
50
|
+
Get a psycopg2 connection.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
url: Database URL. If None, resolved from environment.
|
|
54
|
+
connect_timeout: Connection timeout in seconds.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
psycopg2 connection object
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ImportError: If psycopg2 is not installed
|
|
61
|
+
RuntimeError: If no database URL is available
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
import psycopg2
|
|
65
|
+
except ImportError as e:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"psycopg2 is required for db.ops utilities. "
|
|
68
|
+
"Install with: pip install psycopg2-binary"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
if url is None:
|
|
72
|
+
url = get_database_url_from_env(required=True)
|
|
73
|
+
|
|
74
|
+
# Add connect_timeout to connection options
|
|
75
|
+
return psycopg2.connect(url, connect_timeout=connect_timeout)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wait_for_database(
|
|
79
|
+
url: Optional[str] = None,
|
|
80
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT,
|
|
81
|
+
interval: float = DEFAULT_WAIT_INTERVAL,
|
|
82
|
+
verbose: bool = True,
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Wait for database to be ready, with retries.
|
|
86
|
+
|
|
87
|
+
Useful for container startup scripts where the database may not
|
|
88
|
+
be immediately available.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
url: Database URL. If None, resolved from environment.
|
|
92
|
+
timeout: Maximum time to wait in seconds (default: 30)
|
|
93
|
+
interval: Time between retry attempts in seconds (default: 1.0)
|
|
94
|
+
verbose: If True, log progress messages
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if database is ready, False if timeout reached
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
>>> # In container startup script
|
|
101
|
+
>>> if not wait_for_database(timeout=60):
|
|
102
|
+
... sys.exit(1)
|
|
103
|
+
>>> # Database is ready, continue with app startup
|
|
104
|
+
"""
|
|
105
|
+
if url is None:
|
|
106
|
+
url = get_database_url_from_env(required=True)
|
|
107
|
+
|
|
108
|
+
start = time.monotonic()
|
|
109
|
+
attempt = 0
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
attempt += 1
|
|
113
|
+
elapsed = time.monotonic() - start
|
|
114
|
+
|
|
115
|
+
if elapsed >= timeout:
|
|
116
|
+
if verbose:
|
|
117
|
+
logger.error(
|
|
118
|
+
f"Database not ready after {timeout}s ({attempt} attempts)"
|
|
119
|
+
)
|
|
120
|
+
_flush()
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
conn = _get_connection(url, connect_timeout=min(5, int(timeout - elapsed)))
|
|
125
|
+
conn.close()
|
|
126
|
+
if verbose:
|
|
127
|
+
logger.info(f"Database ready after {elapsed:.1f}s ({attempt} attempts)")
|
|
128
|
+
_flush()
|
|
129
|
+
return True
|
|
130
|
+
except Exception as e:
|
|
131
|
+
if verbose:
|
|
132
|
+
remaining = timeout - elapsed
|
|
133
|
+
logger.debug(
|
|
134
|
+
f"Database not ready ({e}), retrying... ({remaining:.0f}s remaining)"
|
|
135
|
+
)
|
|
136
|
+
_flush()
|
|
137
|
+
time.sleep(interval)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run_sync_sql(
|
|
141
|
+
sql: str,
|
|
142
|
+
params: Optional[Sequence[Any]] = None,
|
|
143
|
+
url: Optional[str] = None,
|
|
144
|
+
timeout: int = DEFAULT_STATEMENT_TIMEOUT,
|
|
145
|
+
fetch: bool = False,
|
|
146
|
+
) -> Optional[list[tuple[Any, ...]]]:
|
|
147
|
+
"""
|
|
148
|
+
Execute SQL synchronously with a statement timeout.
|
|
149
|
+
|
|
150
|
+
This is useful for one-off administrative queries that don't fit
|
|
151
|
+
the normal async SQLAlchemy workflow.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
sql: SQL statement to execute
|
|
155
|
+
params: Optional parameters for parameterized queries
|
|
156
|
+
url: Database URL. If None, resolved from environment.
|
|
157
|
+
timeout: Statement timeout in seconds (default: 30)
|
|
158
|
+
fetch: If True, return fetched rows; if False, return None
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of tuples if fetch=True, otherwise None
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
psycopg2.Error: On database errors
|
|
165
|
+
TimeoutError: If statement exceeds timeout
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
>>> # Run a maintenance query
|
|
169
|
+
>>> run_sync_sql("VACUUM ANALYZE users")
|
|
170
|
+
>>>
|
|
171
|
+
>>> # Fetch data with timeout
|
|
172
|
+
>>> rows = run_sync_sql(
|
|
173
|
+
... "SELECT id, name FROM users WHERE active = %s",
|
|
174
|
+
... params=(True,),
|
|
175
|
+
... fetch=True,
|
|
176
|
+
... timeout=10
|
|
177
|
+
... )
|
|
178
|
+
"""
|
|
179
|
+
conn = _get_connection(url)
|
|
180
|
+
try:
|
|
181
|
+
with conn.cursor() as cur:
|
|
182
|
+
# Set statement timeout (PostgreSQL-specific)
|
|
183
|
+
cur.execute(f"SET statement_timeout = '{timeout}s'")
|
|
184
|
+
|
|
185
|
+
if params:
|
|
186
|
+
cur.execute(sql, params)
|
|
187
|
+
else:
|
|
188
|
+
cur.execute(sql)
|
|
189
|
+
|
|
190
|
+
if fetch:
|
|
191
|
+
return cast(list[tuple[Any, ...]], cur.fetchall())
|
|
192
|
+
|
|
193
|
+
conn.commit()
|
|
194
|
+
return None
|
|
195
|
+
finally:
|
|
196
|
+
conn.close()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def kill_blocking_queries(
|
|
200
|
+
table_name: str,
|
|
201
|
+
url: Optional[str] = None,
|
|
202
|
+
timeout: int = DEFAULT_STATEMENT_TIMEOUT,
|
|
203
|
+
dry_run: bool = False,
|
|
204
|
+
) -> list[dict[str, Any]]:
|
|
205
|
+
"""
|
|
206
|
+
Terminate queries blocking operations on a specific table.
|
|
207
|
+
|
|
208
|
+
This is useful before DROP TABLE or ALTER TABLE operations that
|
|
209
|
+
might be blocked by long-running queries or idle transactions.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
table_name: Name of the table (can include schema as 'schema.table')
|
|
213
|
+
url: Database URL. If None, resolved from environment.
|
|
214
|
+
timeout: Statement timeout in seconds (default: 30)
|
|
215
|
+
dry_run: If True, only report blocking queries without terminating
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
List of dicts with info about terminated (or found) queries:
|
|
219
|
+
[{"pid": 123, "query": "SELECT...", "state": "active", "terminated": True}]
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
>>> # Check what would be terminated
|
|
223
|
+
>>> blocking = kill_blocking_queries("embeddings", dry_run=True)
|
|
224
|
+
>>> print(f"Found {len(blocking)} blocking queries")
|
|
225
|
+
>>>
|
|
226
|
+
>>> # Actually terminate them
|
|
227
|
+
>>> kill_blocking_queries("embeddings")
|
|
228
|
+
"""
|
|
229
|
+
# Query to find blocking queries on a table
|
|
230
|
+
find_blocking_sql = """
|
|
231
|
+
SELECT pid, state, query, age(clock_timestamp(), query_start) as duration
|
|
232
|
+
FROM pg_stat_activity
|
|
233
|
+
WHERE pid != pg_backend_pid()
|
|
234
|
+
AND state != 'idle'
|
|
235
|
+
AND (
|
|
236
|
+
query ILIKE %s
|
|
237
|
+
OR query ILIKE %s
|
|
238
|
+
OR query ILIKE %s
|
|
239
|
+
)
|
|
240
|
+
ORDER BY query_start;
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
# Patterns to match queries involving the table
|
|
244
|
+
patterns = (
|
|
245
|
+
f"%{table_name}%",
|
|
246
|
+
f"%{table_name.split('.')[-1]}%", # Just table name without schema
|
|
247
|
+
f"%{table_name.replace('.', '%')}%", # Handle schema.table pattern
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
conn = _get_connection(url)
|
|
251
|
+
terminated: list[dict[str, Any]] = []
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
with conn.cursor() as cur:
|
|
255
|
+
cur.execute(f"SET statement_timeout = '{timeout}s'")
|
|
256
|
+
cur.execute(find_blocking_sql, patterns)
|
|
257
|
+
rows = cur.fetchall()
|
|
258
|
+
|
|
259
|
+
for pid, state, query, duration in rows:
|
|
260
|
+
info = {
|
|
261
|
+
"pid": pid,
|
|
262
|
+
"state": state,
|
|
263
|
+
"query": query[:200] + "..." if len(query) > 200 else query,
|
|
264
|
+
"duration": str(duration),
|
|
265
|
+
"terminated": False,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if not dry_run:
|
|
269
|
+
try:
|
|
270
|
+
cur.execute("SELECT pg_terminate_backend(%s)", (pid,))
|
|
271
|
+
info["terminated"] = True
|
|
272
|
+
logger.info(f"Terminated query PID {pid}: {query[:100]}...")
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning(f"Failed to terminate PID {pid}: {e}")
|
|
275
|
+
info["error"] = str(e)
|
|
276
|
+
|
|
277
|
+
terminated.append(info)
|
|
278
|
+
|
|
279
|
+
conn.commit()
|
|
280
|
+
finally:
|
|
281
|
+
conn.close()
|
|
282
|
+
|
|
283
|
+
_flush()
|
|
284
|
+
return terminated
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def drop_table_safe(
|
|
288
|
+
table_name: str,
|
|
289
|
+
url: Optional[str] = None,
|
|
290
|
+
timeout: int = DEFAULT_STATEMENT_TIMEOUT,
|
|
291
|
+
kill_blocking: bool = True,
|
|
292
|
+
if_exists: bool = True,
|
|
293
|
+
cascade: bool = False,
|
|
294
|
+
) -> bool:
|
|
295
|
+
"""
|
|
296
|
+
Drop a table safely with lock handling.
|
|
297
|
+
|
|
298
|
+
Handles common issues with DROP TABLE:
|
|
299
|
+
- Terminates blocking queries first (optional)
|
|
300
|
+
- Uses statement timeout to avoid hanging
|
|
301
|
+
- Handles 'table does not exist' gracefully
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
table_name: Name of table to drop (can include schema)
|
|
305
|
+
url: Database URL. If None, resolved from environment.
|
|
306
|
+
timeout: Statement timeout in seconds (default: 30)
|
|
307
|
+
kill_blocking: If True, terminate blocking queries first (default: True)
|
|
308
|
+
if_exists: If True, don't error if table doesn't exist (default: True)
|
|
309
|
+
cascade: If True, drop dependent objects (default: False)
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
True if table was dropped (or didn't exist), False on error
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
>>> # Drop table, killing any blocking queries first
|
|
316
|
+
>>> drop_table_safe("embeddings", cascade=True)
|
|
317
|
+
True
|
|
318
|
+
>>>
|
|
319
|
+
>>> # Safe to call even if table doesn't exist
|
|
320
|
+
>>> drop_table_safe("nonexistent_table")
|
|
321
|
+
True
|
|
322
|
+
"""
|
|
323
|
+
if url is None:
|
|
324
|
+
url = get_database_url_from_env(required=True)
|
|
325
|
+
|
|
326
|
+
# Kill blocking queries first if requested
|
|
327
|
+
if kill_blocking:
|
|
328
|
+
blocked = kill_blocking_queries(table_name, url=url, timeout=timeout)
|
|
329
|
+
if blocked:
|
|
330
|
+
logger.info(f"Terminated {len(blocked)} blocking queries before DROP")
|
|
331
|
+
# Brief pause to let connections clean up
|
|
332
|
+
time.sleep(0.5)
|
|
333
|
+
|
|
334
|
+
# Build DROP statement
|
|
335
|
+
drop_sql = "DROP TABLE"
|
|
336
|
+
if if_exists:
|
|
337
|
+
drop_sql += " IF EXISTS"
|
|
338
|
+
drop_sql += f" {table_name}"
|
|
339
|
+
if cascade:
|
|
340
|
+
drop_sql += " CASCADE"
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
run_sync_sql(drop_sql, url=url, timeout=timeout)
|
|
344
|
+
logger.info(f"Dropped table: {table_name}")
|
|
345
|
+
_flush()
|
|
346
|
+
return True
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.error(f"Failed to drop table {table_name}: {e}")
|
|
349
|
+
_flush()
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def get_database_url(
|
|
354
|
+
required: bool = True,
|
|
355
|
+
normalize: bool = True,
|
|
356
|
+
) -> Optional[str]:
|
|
357
|
+
"""
|
|
358
|
+
Convenience wrapper for get_database_url_from_env().
|
|
359
|
+
|
|
360
|
+
This is the recommended way to get the database URL, as it
|
|
361
|
+
handles all common environment variable names and normalizations.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
required: If True, raise RuntimeError when no URL is found
|
|
365
|
+
normalize: If True, convert postgres:// to postgresql://
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Database URL string, or None if not found and not required
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
>>> url = get_database_url()
|
|
372
|
+
>>> print(url)
|
|
373
|
+
'postgresql://user:pass@host:5432/db'
|
|
374
|
+
"""
|
|
375
|
+
return get_database_url_from_env(required=required, normalize=normalize)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"wait_for_database",
|
|
380
|
+
"run_sync_sql",
|
|
381
|
+
"kill_blocking_queries",
|
|
382
|
+
"drop_table_safe",
|
|
383
|
+
"get_database_url",
|
|
384
|
+
]
|