svc-infra 0.1.595__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/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -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 +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- 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 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -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 +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- 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 +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -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 +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -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 +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- 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 +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -6,11 +6,10 @@ from typing import List, Tuple
|
|
|
6
6
|
import sys, pathlib, importlib, pkgutil, traceback
|
|
7
7
|
|
|
8
8
|
from alembic import context
|
|
9
|
+
from sqlalchemy import MetaData
|
|
9
10
|
from sqlalchemy.engine import make_url, URL
|
|
10
11
|
|
|
11
12
|
from svc_infra.db.sql.utils import (
|
|
12
|
-
_coerce_sync_driver,
|
|
13
|
-
_ensure_ssl_default,
|
|
14
13
|
get_database_url_from_env,
|
|
15
14
|
build_engine,
|
|
16
15
|
)
|
|
@@ -103,7 +102,6 @@ if not effective_url:
|
|
|
103
102
|
|
|
104
103
|
u = make_url(effective_url)
|
|
105
104
|
u = _coerce_sync_driver(u)
|
|
106
|
-
u = _ensure_ssl_default(u)
|
|
107
105
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
108
106
|
|
|
109
107
|
|
|
@@ -142,14 +140,16 @@ def _collect_metadata() -> list[object]:
|
|
|
142
140
|
|
|
143
141
|
def _maybe_add(obj: object) -> None:
|
|
144
142
|
md = getattr(obj, "metadata", None) or obj
|
|
145
|
-
|
|
143
|
+
# Strict check: must be actual MetaData instance
|
|
144
|
+
if isinstance(md, MetaData) and md.tables:
|
|
146
145
|
found.append(md)
|
|
147
146
|
|
|
148
147
|
def _scan_module_objects(mod: object) -> None:
|
|
149
148
|
try:
|
|
150
149
|
for val in vars(mod).values():
|
|
151
150
|
md = getattr(val, "metadata", None) or None
|
|
152
|
-
if
|
|
151
|
+
# Only add if it's a SQLAlchemy MetaData object (has tables dict, not a callable/generator)
|
|
152
|
+
if md is not None and hasattr(md, "tables") and isinstance(getattr(md, "tables", None), dict):
|
|
153
153
|
found.append(md)
|
|
154
154
|
except Exception:
|
|
155
155
|
pass
|
|
@@ -191,9 +191,16 @@ def _collect_metadata() -> list[object]:
|
|
|
191
191
|
if name not in pkgs:
|
|
192
192
|
pkgs.append(name)
|
|
193
193
|
|
|
194
|
-
#
|
|
194
|
+
# Only attempt a bare 'models' import if discoverable to avoid noisy tracebacks
|
|
195
195
|
if "models" not in pkgs:
|
|
196
|
-
|
|
196
|
+
try:
|
|
197
|
+
spec = getattr(importlib, "util", None)
|
|
198
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
199
|
+
if spec.find_spec("models") is not None:
|
|
200
|
+
pkgs.append("models")
|
|
201
|
+
except Exception:
|
|
202
|
+
# If discovery fails, skip adding bare 'models'
|
|
203
|
+
pass
|
|
197
204
|
|
|
198
205
|
def _import_and_collect(modname: str):
|
|
199
206
|
try:
|
|
@@ -239,6 +246,21 @@ def _collect_metadata() -> list[object]:
|
|
|
239
246
|
except Exception:
|
|
240
247
|
_note("ModelBase import", False, traceback.format_exc())
|
|
241
248
|
|
|
249
|
+
# Core security models (AuthSession, RefreshToken, etc.)
|
|
250
|
+
try:
|
|
251
|
+
import svc_infra.security.models # noqa: F401
|
|
252
|
+
_note("svc_infra.security.models", True, None)
|
|
253
|
+
except Exception:
|
|
254
|
+
_note("svc_infra.security.models", False, traceback.format_exc())
|
|
255
|
+
|
|
256
|
+
# OAuth models (opt-in via environment variable)
|
|
257
|
+
if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
|
|
258
|
+
try:
|
|
259
|
+
import svc_infra.security.oauth_models # noqa: F401
|
|
260
|
+
_note("svc_infra.security.oauth_models", True, None)
|
|
261
|
+
except Exception:
|
|
262
|
+
_note("svc_infra.security.oauth_models", False, traceback.format_exc())
|
|
263
|
+
|
|
242
264
|
# Optional: autobind API key model
|
|
243
265
|
try:
|
|
244
266
|
from svc_infra.db.sql.apikey import try_autobind_apikey_model
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from .service import SqlService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TenantSqlService(SqlService):
|
|
12
|
+
"""
|
|
13
|
+
SQL service wrapper that automatically scopes operations to a tenant.
|
|
14
|
+
|
|
15
|
+
- Adds a where filter (model.tenant_field == tenant_id) for list/get/update/delete/search/count.
|
|
16
|
+
- On create, if the model has the tenant field and it's not set in data, injects tenant_id.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo, *, tenant_id: str, tenant_field: str = "tenant_id"):
|
|
20
|
+
super().__init__(repo)
|
|
21
|
+
self.tenant_id = tenant_id
|
|
22
|
+
self.tenant_field = tenant_field
|
|
23
|
+
|
|
24
|
+
def _where(self) -> Sequence[Any]:
|
|
25
|
+
model = self.repo.model
|
|
26
|
+
col = getattr(model, self.tenant_field, None)
|
|
27
|
+
if col is None:
|
|
28
|
+
return []
|
|
29
|
+
return [col == self.tenant_id]
|
|
30
|
+
|
|
31
|
+
async def list(self, session: AsyncSession, *, limit: int, offset: int, order_by=None):
|
|
32
|
+
return await self.repo.list(
|
|
33
|
+
session, limit=limit, offset=offset, order_by=order_by, where=self._where()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async def count(self, session: AsyncSession) -> int:
|
|
37
|
+
return await self.repo.count(session, where=self._where())
|
|
38
|
+
|
|
39
|
+
async def get(self, session: AsyncSession, id_value: Any):
|
|
40
|
+
return await self.repo.get(session, id_value, where=self._where())
|
|
41
|
+
|
|
42
|
+
async def create(self, session: AsyncSession, data: dict[str, Any]):
|
|
43
|
+
data = await self.pre_create(data)
|
|
44
|
+
# inject tenant_id if model supports it and value missing
|
|
45
|
+
if self.tenant_field in self.repo._model_columns() and self.tenant_field not in data:
|
|
46
|
+
data[self.tenant_field] = self.tenant_id
|
|
47
|
+
return await self.repo.create(session, data)
|
|
48
|
+
|
|
49
|
+
async def update(self, session: AsyncSession, id_value: Any, data: dict[str, Any]):
|
|
50
|
+
data = await self.pre_update(data)
|
|
51
|
+
return await self.repo.update(session, id_value, data, where=self._where())
|
|
52
|
+
|
|
53
|
+
async def delete(self, session: AsyncSession, id_value: Any) -> bool:
|
|
54
|
+
return await self.repo.delete(session, id_value, where=self._where())
|
|
55
|
+
|
|
56
|
+
async def search(
|
|
57
|
+
self,
|
|
58
|
+
session: AsyncSession,
|
|
59
|
+
*,
|
|
60
|
+
q: str,
|
|
61
|
+
fields: Sequence[str],
|
|
62
|
+
limit: int,
|
|
63
|
+
offset: int,
|
|
64
|
+
order_by=None,
|
|
65
|
+
):
|
|
66
|
+
return await self.repo.search(
|
|
67
|
+
session,
|
|
68
|
+
q=q,
|
|
69
|
+
fields=fields,
|
|
70
|
+
limit=limit,
|
|
71
|
+
offset=offset,
|
|
72
|
+
order_by=order_by,
|
|
73
|
+
where=self._where(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def count_filtered(self, session: AsyncSession, *, q: str, fields: Sequence[str]) -> int:
|
|
77
|
+
return await self.repo.count_filtered(session, q=q, fields=fields, where=self._where())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["TenantSqlService"]
|
svc_infra/db/sql/uniq.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from sqlalchemy import Index, func
|
|
6
7
|
|
|
@@ -9,13 +10,13 @@ from svc_infra.db.utils import as_tuple as _as_tuple
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def make_unique_sql_indexes(
|
|
12
|
-
model:
|
|
13
|
+
model: type[Any],
|
|
13
14
|
*,
|
|
14
15
|
unique_cs: Iterable[KeySpec] = (),
|
|
15
16
|
unique_ci: Iterable[KeySpec] = (),
|
|
16
|
-
tenant_field:
|
|
17
|
+
tenant_field: str | None = None,
|
|
17
18
|
name_prefix: str = "uq",
|
|
18
|
-
) ->
|
|
19
|
+
) -> list[Index]:
|
|
19
20
|
"""Return SQLAlchemy Index objects that enforce uniqueness.
|
|
20
21
|
|
|
21
22
|
- unique_cs: case-sensitive unique specs
|
|
@@ -26,12 +27,12 @@ def make_unique_sql_indexes(
|
|
|
26
27
|
|
|
27
28
|
Declare right after your model class; Alembic or metadata.create_all will pick them up.
|
|
28
29
|
"""
|
|
29
|
-
idxs:
|
|
30
|
+
idxs: list[Index] = []
|
|
30
31
|
|
|
31
32
|
def _col(name: str):
|
|
32
33
|
return getattr(model, name)
|
|
33
34
|
|
|
34
|
-
def _to_sa_cols(spec:
|
|
35
|
+
def _to_sa_cols(spec: tuple[str, ...], *, ci: bool):
|
|
35
36
|
cols = []
|
|
36
37
|
for cname in spec:
|
|
37
38
|
c = _col(cname)
|
|
@@ -40,7 +41,7 @@ def make_unique_sql_indexes(
|
|
|
40
41
|
|
|
41
42
|
tenant_col = _col(tenant_field) if tenant_field else None
|
|
42
43
|
|
|
43
|
-
def _name(ci: bool, spec:
|
|
44
|
+
def _name(ci: bool, spec: tuple[str, ...], null_bucket: str | None = None):
|
|
44
45
|
parts = [name_prefix, model.__tablename__]
|
|
45
46
|
if tenant_field:
|
|
46
47
|
parts.append(tenant_field)
|
svc_infra/db/sql/uniq_hooks.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from fastapi import HTTPException
|
|
6
7
|
from sqlalchemy import func
|
|
@@ -11,14 +12,14 @@ from svc_infra.db.sql.service_with_hooks import SqlServiceWithHooks
|
|
|
11
12
|
|
|
12
13
|
from .uniq import _as_tuple
|
|
13
14
|
|
|
14
|
-
ColumnSpec =
|
|
15
|
+
ColumnSpec = str | Sequence[str]
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
def _all_present(data:
|
|
18
|
+
def _all_present(data: dict[str, Any], fields: Sequence[str]) -> bool:
|
|
18
19
|
return all(f in data for f in fields)
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def _nice_label(fields: Sequence[str], data:
|
|
22
|
+
def _nice_label(fields: Sequence[str], data: dict[str, Any]) -> str:
|
|
22
23
|
if len(fields) == 1:
|
|
23
24
|
f = fields[0]
|
|
24
25
|
return f"{f}={data.get(f)!r}"
|
|
@@ -30,10 +31,10 @@ def dedupe_sql_service(
|
|
|
30
31
|
*,
|
|
31
32
|
unique_cs: Iterable[ColumnSpec] = (),
|
|
32
33
|
unique_ci: Iterable[ColumnSpec] = (),
|
|
33
|
-
tenant_field:
|
|
34
|
-
messages:
|
|
35
|
-
pre_create:
|
|
36
|
-
pre_update:
|
|
34
|
+
tenant_field: str | None = None,
|
|
35
|
+
messages: dict[tuple[str, ...], str] | None = None,
|
|
36
|
+
pre_create: Callable[[dict], dict] | None = None,
|
|
37
|
+
pre_update: Callable[[dict], dict] | None = None,
|
|
37
38
|
):
|
|
38
39
|
"""
|
|
39
40
|
Build a Service subclass with uniqueness pre-checks:
|
|
@@ -46,9 +47,9 @@ def dedupe_sql_service(
|
|
|
46
47
|
messages = messages or {}
|
|
47
48
|
|
|
48
49
|
def _build_where(
|
|
49
|
-
spec:
|
|
50
|
+
spec: tuple[str, ...], data: dict[str, Any], *, ci: bool, exclude_id: Any | None
|
|
50
51
|
):
|
|
51
|
-
clauses:
|
|
52
|
+
clauses: list[Any] = []
|
|
52
53
|
for col_name in spec:
|
|
53
54
|
col = getattr(Model, col_name)
|
|
54
55
|
val = data.get(col_name)
|
|
@@ -73,7 +74,7 @@ def dedupe_sql_service(
|
|
|
73
74
|
|
|
74
75
|
return clauses
|
|
75
76
|
|
|
76
|
-
async def _precheck(session, data:
|
|
77
|
+
async def _precheck(session, data: dict[str, Any], *, exclude_id: Any | None) -> None:
|
|
77
78
|
# Check CI specs first to catch the broadest conflicts, then CS.
|
|
78
79
|
for ci, spec_list in ((True, unique_ci), (False, unique_cs)):
|
|
79
80
|
for spec in spec_list:
|
svc_infra/db/sql/utils.py
CHANGED
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
|
+
from collections.abc import Sequence
|
|
5
6
|
from pathlib import Path
|
|
6
|
-
from typing import TYPE_CHECKING, Any,
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
8
|
|
|
8
9
|
from alembic.config import Config
|
|
9
10
|
from dotenv import load_dotenv
|
|
@@ -17,24 +18,24 @@ if TYPE_CHECKING:
|
|
|
17
18
|
from sqlalchemy.engine import Engine as SyncEngine
|
|
18
19
|
from sqlalchemy.ext.asyncio import AsyncEngine as AsyncEngineType
|
|
19
20
|
else:
|
|
20
|
-
SyncEngine = Any # type: ignore
|
|
21
|
-
AsyncEngineType = Any # type: ignore
|
|
21
|
+
SyncEngine = Any # type: ignore[assignment]
|
|
22
|
+
AsyncEngineType = Any # type: ignore[assignment]
|
|
22
23
|
|
|
23
24
|
try:
|
|
24
25
|
# Runtime import (may be missing if async extras aren’t installed)
|
|
25
|
-
from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
|
|
26
|
+
from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
|
|
26
27
|
except Exception: # pragma: no cover - optional dep
|
|
27
|
-
_create_async_engine = None # type: ignore
|
|
28
|
+
_create_async_engine = None # type: ignore[assignment]
|
|
28
29
|
|
|
29
30
|
try:
|
|
30
|
-
from sqlalchemy import create_engine as _create_engine
|
|
31
|
+
from sqlalchemy import create_engine as _create_engine
|
|
31
32
|
except Exception: # pragma: no cover - optional env
|
|
32
|
-
_create_engine = None # type: ignore
|
|
33
|
+
_create_engine = None # type: ignore[assignment]
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
def prepare_process_env(
|
|
36
37
|
project_root: Path | str,
|
|
37
|
-
discover_packages:
|
|
38
|
+
discover_packages: Sequence[str] | None = None,
|
|
38
39
|
) -> None:
|
|
39
40
|
"""
|
|
40
41
|
Prepare process environment so Alembic can import the project cleanly.
|
|
@@ -60,7 +61,7 @@ def prepare_process_env(
|
|
|
60
61
|
os.environ["ALEMBIC_DISCOVER_PACKAGES"] = ",".join(discover_packages)
|
|
61
62
|
|
|
62
63
|
|
|
63
|
-
def _read_secret_from_file(path: str) ->
|
|
64
|
+
def _read_secret_from_file(path: str) -> str | None:
|
|
64
65
|
"""Return file contents if path exists, else None."""
|
|
65
66
|
try:
|
|
66
67
|
p = Path(path)
|
|
@@ -71,7 +72,7 @@ def _read_secret_from_file(path: str) -> Optional[str]:
|
|
|
71
72
|
return None
|
|
72
73
|
|
|
73
74
|
|
|
74
|
-
def _compose_url_from_parts() ->
|
|
75
|
+
def _compose_url_from_parts() -> str | None:
|
|
75
76
|
"""
|
|
76
77
|
Compose a SQLAlchemy URL from component env vars.
|
|
77
78
|
Supports private DNS hostnames and Unix sockets.
|
|
@@ -124,18 +125,62 @@ def _compose_url_from_parts() -> Optional[str]:
|
|
|
124
125
|
# ---------- Environment helpers ----------
|
|
125
126
|
|
|
126
127
|
|
|
128
|
+
def _normalize_database_url(url: str) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Normalize database URL for SQLAlchemy compatibility.
|
|
131
|
+
|
|
132
|
+
Handles:
|
|
133
|
+
- postgres:// → postgresql:// (Heroku/Railway legacy format)
|
|
134
|
+
- Strips whitespace
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
url: Raw database URL string
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Normalized URL suitable for SQLAlchemy
|
|
141
|
+
"""
|
|
142
|
+
url = url.strip()
|
|
143
|
+
# Heroku and Railway historically use 'postgres://' which SQLAlchemy doesn't accept
|
|
144
|
+
if url.startswith("postgres://"):
|
|
145
|
+
url = "postgresql://" + url[len("postgres://") :]
|
|
146
|
+
return url
|
|
147
|
+
|
|
148
|
+
|
|
127
149
|
def get_database_url_from_env(
|
|
128
150
|
required: bool = True,
|
|
129
151
|
env_vars: Sequence[str] = DEFAULT_DB_ENV_VARS,
|
|
130
|
-
|
|
152
|
+
normalize: bool = True,
|
|
153
|
+
) -> str | None:
|
|
131
154
|
"""
|
|
132
155
|
Resolve the database connection string, with support for:
|
|
133
|
-
- Primary env vars (in order): DEFAULT_DB_ENV_VARS
|
|
156
|
+
- Primary env vars (in order): DEFAULT_DB_ENV_VARS
|
|
157
|
+
(SQL_URL, DB_URL, DATABASE_URL, DATABASE_URL_PRIVATE, PRIVATE_SQL_URL)
|
|
134
158
|
- Secret file pointers: <NAME>_FILE (reads file contents).
|
|
135
159
|
- Well-known locations: SQL_URL_FILE, /run/secrets/database_url.
|
|
136
160
|
- Composed from parts: DB_* (host, port, name, user, password, params).
|
|
137
|
-
|
|
161
|
+
|
|
162
|
+
When a value is found, it is also written back into os.environ["SQL_URL"]
|
|
163
|
+
for downstream code.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
required: If True, raise RuntimeError when no URL is found
|
|
167
|
+
env_vars: Sequence of environment variable names to check
|
|
168
|
+
normalize: If True, convert postgres:// to postgresql:// (default: True)
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Database URL string, or None if not found and not required
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
RuntimeError: If required=True and no URL is found
|
|
138
175
|
"""
|
|
176
|
+
|
|
177
|
+
def _finalize(url: str) -> str:
|
|
178
|
+
"""Normalize and cache the URL."""
|
|
179
|
+
if normalize:
|
|
180
|
+
url = _normalize_database_url(url)
|
|
181
|
+
os.environ["SQL_URL"] = url
|
|
182
|
+
return url
|
|
183
|
+
|
|
139
184
|
# Load .env without clobbering existing process env
|
|
140
185
|
load_dotenv(override=False)
|
|
141
186
|
|
|
@@ -150,10 +195,8 @@ def get_database_url_from_env(
|
|
|
150
195
|
if os.path.isabs(s) and Path(s).exists():
|
|
151
196
|
file_val = _read_secret_from_file(s)
|
|
152
197
|
if file_val:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
os.environ["SQL_URL"] = s
|
|
156
|
-
return s
|
|
198
|
+
return _finalize(file_val)
|
|
199
|
+
return _finalize(s)
|
|
157
200
|
|
|
158
201
|
# Companion NAME_FILE secret path (e.g., SQL_URL_FILE)
|
|
159
202
|
file_key = f"{key}_FILE"
|
|
@@ -161,32 +204,28 @@ def get_database_url_from_env(
|
|
|
161
204
|
if file_path:
|
|
162
205
|
file_val = _read_secret_from_file(file_path)
|
|
163
206
|
if file_val:
|
|
164
|
-
|
|
165
|
-
return file_val
|
|
207
|
+
return _finalize(file_val)
|
|
166
208
|
|
|
167
209
|
# 2) Conventional secret envs
|
|
168
210
|
file_path = os.getenv("SQL_URL_FILE")
|
|
169
211
|
if file_path:
|
|
170
212
|
file_val = _read_secret_from_file(file_path)
|
|
171
213
|
if file_val:
|
|
172
|
-
|
|
173
|
-
return file_val
|
|
214
|
+
return _finalize(file_val)
|
|
174
215
|
|
|
175
216
|
# 3) Docker/K8s default secret mount
|
|
176
217
|
file_val = _read_secret_from_file("/run/secrets/database_url")
|
|
177
218
|
if file_val:
|
|
178
|
-
|
|
179
|
-
return file_val
|
|
219
|
+
return _finalize(file_val)
|
|
180
220
|
|
|
181
221
|
# 4) Compose from parts (DB_DIALECT/DB_DRIVER/DB_HOST/.../DB_PARAMS)
|
|
182
222
|
composed = _compose_url_from_parts()
|
|
183
223
|
if composed:
|
|
184
|
-
|
|
185
|
-
return composed
|
|
224
|
+
return _finalize(composed)
|
|
186
225
|
|
|
187
226
|
if required:
|
|
188
227
|
raise RuntimeError(
|
|
189
|
-
"Database URL not set. Set SQL_URL
|
|
228
|
+
"Database URL not set. Set SQL_URL, DATABASE_URL, or DATABASE_URL_PRIVATE, "
|
|
190
229
|
"or provide DB_* parts (DB_HOST, DB_NAME, etc.), or a *_FILE secret."
|
|
191
230
|
)
|
|
192
231
|
return None
|
|
@@ -196,10 +235,17 @@ def _ensure_timeout_default(u: URL) -> URL:
|
|
|
196
235
|
"""
|
|
197
236
|
Ensure a conservative connection timeout is present for libpq-based drivers.
|
|
198
237
|
For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
|
|
238
|
+
For asyncpg, timeout is set via connect_args (not query string).
|
|
199
239
|
"""
|
|
200
240
|
backend = (u.get_backend_name() or "").lower()
|
|
201
241
|
if backend not in ("postgresql", "postgres"):
|
|
202
242
|
return u
|
|
243
|
+
|
|
244
|
+
# asyncpg doesn't support connect_timeout in query string - use connect_args instead
|
|
245
|
+
dn = (u.drivername or "").lower()
|
|
246
|
+
if "+asyncpg" in dn:
|
|
247
|
+
return u
|
|
248
|
+
|
|
203
249
|
if "connect_timeout" in u.query:
|
|
204
250
|
return u
|
|
205
251
|
# Default 10s unless overridden
|
|
@@ -216,7 +262,7 @@ def is_async_url(url: URL | str) -> bool:
|
|
|
216
262
|
return bool(ASYNC_DRIVER_HINT.search(dn))
|
|
217
263
|
|
|
218
264
|
|
|
219
|
-
def with_database(url: URL | str, database:
|
|
265
|
+
def with_database(url: URL | str, database: str | None) -> URL:
|
|
220
266
|
"""Return a copy of URL with the database name replaced.
|
|
221
267
|
|
|
222
268
|
Works for most dialects. For SQLite/DuckDB file URLs, `database` is the file path.
|
|
@@ -337,9 +383,8 @@ def _ensure_ssl_default(u: URL) -> URL:
|
|
|
337
383
|
mode = (mode_env or "").strip()
|
|
338
384
|
|
|
339
385
|
if "+asyncpg" in driver:
|
|
340
|
-
# asyncpg:
|
|
341
|
-
|
|
342
|
-
return u.set(query={**u.query, "ssl": "true"})
|
|
386
|
+
# asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
|
|
387
|
+
# Do not add ssl parameter to URL query for asyncpg
|
|
343
388
|
return u
|
|
344
389
|
else:
|
|
345
390
|
# libpq-based drivers: use sslmode (default 'require' for hosted PG)
|
|
@@ -366,7 +411,7 @@ def _certifi_ca() -> str | None:
|
|
|
366
411
|
return None
|
|
367
412
|
|
|
368
413
|
|
|
369
|
-
def build_engine(url: URL | str, echo: bool = False) ->
|
|
414
|
+
def build_engine(url: URL | str, echo: bool = False) -> SyncEngine | AsyncEngineType:
|
|
370
415
|
u = make_url(url) if isinstance(url, str) else url
|
|
371
416
|
|
|
372
417
|
# Keep your existing PG helpers
|
|
@@ -382,10 +427,18 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
382
427
|
"Async driver URL provided but SQLAlchemy async extras are not available."
|
|
383
428
|
)
|
|
384
429
|
|
|
385
|
-
# asyncpg: honor connection timeout
|
|
430
|
+
# asyncpg: honor connection timeout only (NOT connect_timeout)
|
|
386
431
|
if "+asyncpg" in (u.drivername or ""):
|
|
387
432
|
connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
|
|
388
433
|
|
|
434
|
+
# asyncpg doesn't accept sslmode or ssl=true in query params
|
|
435
|
+
# Remove these and set ssl='require' in connect_args
|
|
436
|
+
if "ssl" in u.query or "sslmode" in u.query:
|
|
437
|
+
new_query = {k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")}
|
|
438
|
+
u = u.set(query=new_query)
|
|
439
|
+
# Set ssl in connect_args - 'require' is safest for hosted databases
|
|
440
|
+
connect_args["ssl"] = "require"
|
|
441
|
+
|
|
389
442
|
# NEW: aiomysql SSL default
|
|
390
443
|
if "+aiomysql" in (u.drivername or "") and not any(
|
|
391
444
|
k in u.query for k in ("ssl", "ssl_ca", "sslmode")
|
|
@@ -404,10 +457,10 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
404
457
|
except Exception:
|
|
405
458
|
connect_args["ssl"] = True # minimal hint to enable TLS
|
|
406
459
|
|
|
407
|
-
|
|
460
|
+
async_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
|
|
408
461
|
if connect_args:
|
|
409
|
-
|
|
410
|
-
return _create_async_engine(u, **
|
|
462
|
+
async_engine_kwargs["connect_args"] = connect_args
|
|
463
|
+
return _create_async_engine(u, **async_engine_kwargs)
|
|
411
464
|
|
|
412
465
|
# ----------------- SYNC -----------------
|
|
413
466
|
u = _coerce_sync_driver(u)
|
|
@@ -435,10 +488,10 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
435
488
|
# Optional: if your provider requires it, you can also add:
|
|
436
489
|
# connect_args.setdefault("client_flag", 0)
|
|
437
490
|
|
|
438
|
-
|
|
491
|
+
sync_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
|
|
439
492
|
if connect_args:
|
|
440
|
-
|
|
441
|
-
return _create_engine(u, **
|
|
493
|
+
sync_engine_kwargs["connect_args"] = connect_args
|
|
494
|
+
return _create_engine(u, **sync_engine_kwargs)
|
|
442
495
|
|
|
443
496
|
|
|
444
497
|
# ---------- Identifier quoting helpers ----------
|
|
@@ -485,7 +538,7 @@ async def _pg_create_database_async(url: URL) -> None:
|
|
|
485
538
|
)
|
|
486
539
|
if not exists:
|
|
487
540
|
quoted = _pg_quote_ident(target_db)
|
|
488
|
-
await conn.execution_options(isolation_level="AUTOCOMMIT").execute(
|
|
541
|
+
await conn.execution_options(isolation_level="AUTOCOMMIT").execute( # type: ignore[attr-defined]
|
|
489
542
|
text(f'CREATE DATABASE "{quoted}"')
|
|
490
543
|
)
|
|
491
544
|
except DBAPIError as e:
|
|
@@ -789,17 +842,19 @@ def ensure_database_exists(url: URL | str) -> None:
|
|
|
789
842
|
try:
|
|
790
843
|
eng = build_engine(u)
|
|
791
844
|
if is_async_url(u):
|
|
845
|
+
async_eng = cast("AsyncEngineType", eng)
|
|
792
846
|
|
|
793
847
|
async def _ping_and_dispose():
|
|
794
|
-
async with
|
|
848
|
+
async with async_eng.begin() as conn:
|
|
795
849
|
await conn.execute(text("SELECT 1"))
|
|
796
|
-
await
|
|
850
|
+
await async_eng.dispose()
|
|
797
851
|
|
|
798
852
|
asyncio.run(_ping_and_dispose())
|
|
799
853
|
else:
|
|
800
|
-
|
|
854
|
+
sync_eng = cast("SyncEngine", eng)
|
|
855
|
+
with sync_eng.begin() as conn:
|
|
801
856
|
conn.execute(text("SELECT 1"))
|
|
802
|
-
|
|
857
|
+
sync_eng.dispose()
|
|
803
858
|
except OperationalError as exc: # pragma: no cover (depends on env)
|
|
804
859
|
raise RuntimeError(f"Failed to connect to database: {exc}") from exc
|
|
805
860
|
|
|
@@ -811,9 +866,12 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
811
866
|
return
|
|
812
867
|
|
|
813
868
|
# Gather local revision ids from versions/
|
|
814
|
-
|
|
869
|
+
script_location_str = cfg.get_main_option("script_location")
|
|
870
|
+
if not script_location_str:
|
|
871
|
+
return
|
|
872
|
+
script_location = Path(script_location_str)
|
|
815
873
|
versions_dir = script_location / "versions"
|
|
816
|
-
local_ids:
|
|
874
|
+
local_ids: set[str] = set()
|
|
817
875
|
if versions_dir.exists():
|
|
818
876
|
for p in versions_dir.glob("*.py"):
|
|
819
877
|
try:
|
|
@@ -831,7 +889,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
831
889
|
if is_async_url(url_obj):
|
|
832
890
|
|
|
833
891
|
async def _run() -> None:
|
|
834
|
-
eng = build_engine(url_obj)
|
|
892
|
+
eng = cast("AsyncEngineType", build_engine(url_obj))
|
|
835
893
|
try:
|
|
836
894
|
async with eng.begin() as conn:
|
|
837
895
|
# Do sync-y inspector / SQL via run_sync
|
|
@@ -854,7 +912,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
854
912
|
|
|
855
913
|
asyncio.run(_run())
|
|
856
914
|
else:
|
|
857
|
-
eng = build_engine(url_obj)
|
|
915
|
+
eng = cast("SyncEngine", build_engine(url_obj))
|
|
858
916
|
try:
|
|
859
917
|
with eng.begin() as c:
|
|
860
918
|
insp = inspect(c)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Integer
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Versioned:
|
|
8
|
+
"""Mixin for optimistic locking with integer version.
|
|
9
|
+
|
|
10
|
+
- Initialize version=1 on insert (via default=1)
|
|
11
|
+
- Bump version in app code before commit to detect mismatches.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
svc_infra/db/utils.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
1
2
|
from pathlib import Path
|
|
2
|
-
from typing import Sequence, Tuple, Union
|
|
3
3
|
|
|
4
|
-
KeySpec =
|
|
4
|
+
KeySpec = str | Sequence[str]
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def as_tuple(spec: KeySpec) ->
|
|
7
|
+
def as_tuple(spec: KeySpec) -> tuple[str, ...]:
|
|
8
8
|
return (spec,) if isinstance(spec, str) else tuple(spec)
|
|
9
9
|
|
|
10
10
|
|