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
svc_infra/db/sql/core.py
CHANGED
|
@@ -3,9 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import io
|
|
5
5
|
import os
|
|
6
|
+
from collections.abc import Sequence
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from typing import Optional, Sequence
|
|
9
9
|
|
|
10
10
|
from alembic import command
|
|
11
11
|
from alembic.config import Config
|
|
@@ -30,7 +30,7 @@ from svc_infra.db.sql.utils import (
|
|
|
30
30
|
def init_alembic(
|
|
31
31
|
*,
|
|
32
32
|
script_location: str = "migrations",
|
|
33
|
-
discover_packages:
|
|
33
|
+
discover_packages: Sequence[str] | None = None,
|
|
34
34
|
overwrite: bool = False,
|
|
35
35
|
) -> Path:
|
|
36
36
|
"""
|
|
@@ -140,7 +140,7 @@ def revision(
|
|
|
140
140
|
cfg,
|
|
141
141
|
message=message,
|
|
142
142
|
autogenerate=autogenerate,
|
|
143
|
-
head=head,
|
|
143
|
+
head=head or "head",
|
|
144
144
|
branch_label=branch_label,
|
|
145
145
|
version_path=version_path,
|
|
146
146
|
sql=sql,
|
|
@@ -157,7 +157,7 @@ def revision(
|
|
|
157
157
|
def upgrade(
|
|
158
158
|
revision_target: str = "head",
|
|
159
159
|
*,
|
|
160
|
-
database_url:
|
|
160
|
+
database_url: str | None = None,
|
|
161
161
|
) -> dict:
|
|
162
162
|
"""
|
|
163
163
|
Apply migrations forward.
|
|
@@ -181,7 +181,7 @@ def upgrade(
|
|
|
181
181
|
def downgrade(
|
|
182
182
|
*,
|
|
183
183
|
revision_target: str = "-1",
|
|
184
|
-
database_url:
|
|
184
|
+
database_url: str | None = None,
|
|
185
185
|
) -> dict:
|
|
186
186
|
"""Revert migrations down to the specified revision or relative step.
|
|
187
187
|
|
|
@@ -203,7 +203,7 @@ def downgrade(
|
|
|
203
203
|
def current(
|
|
204
204
|
verbose: bool = False,
|
|
205
205
|
*,
|
|
206
|
-
database_url:
|
|
206
|
+
database_url: str | None = None,
|
|
207
207
|
) -> dict:
|
|
208
208
|
"""Print the current database revision(s)."""
|
|
209
209
|
root = prepare_env()
|
|
@@ -224,7 +224,7 @@ def current(
|
|
|
224
224
|
def history(
|
|
225
225
|
*,
|
|
226
226
|
verbose: bool = False,
|
|
227
|
-
database_url:
|
|
227
|
+
database_url: str | None = None,
|
|
228
228
|
) -> dict:
|
|
229
229
|
"""Show the migration history for this project."""
|
|
230
230
|
root = prepare_env()
|
|
@@ -245,7 +245,7 @@ def history(
|
|
|
245
245
|
def stamp(
|
|
246
246
|
*,
|
|
247
247
|
revision_target: str = "head",
|
|
248
|
-
database_url:
|
|
248
|
+
database_url: str | None = None,
|
|
249
249
|
) -> dict:
|
|
250
250
|
"""Set the current database revision without running migrations. Useful for marking an existing database as up-to-date."""
|
|
251
251
|
root = prepare_env()
|
|
@@ -262,8 +262,8 @@ def stamp(
|
|
|
262
262
|
|
|
263
263
|
def merge_heads(
|
|
264
264
|
*,
|
|
265
|
-
message:
|
|
266
|
-
database_url:
|
|
265
|
+
message: str | None = None,
|
|
266
|
+
database_url: str | None = None,
|
|
267
267
|
) -> dict:
|
|
268
268
|
"""Create a merge revision that joins multiple migration heads."""
|
|
269
269
|
root = prepare_env()
|
|
@@ -309,8 +309,8 @@ def setup_and_migrate(
|
|
|
309
309
|
create_followup_revision: bool = True,
|
|
310
310
|
initial_message: str = "initial schema",
|
|
311
311
|
followup_message: str = "autogen",
|
|
312
|
-
database_url:
|
|
313
|
-
discover_packages:
|
|
312
|
+
database_url: str | None = None,
|
|
313
|
+
discover_packages: Sequence[str] | None = None,
|
|
314
314
|
) -> dict:
|
|
315
315
|
"""
|
|
316
316
|
Ensure DB + Alembic are ready and up-to-date.
|
|
@@ -319,7 +319,7 @@ def setup_and_migrate(
|
|
|
319
319
|
"""
|
|
320
320
|
resolved_url = database_url or get_database_url_from_env(required=True)
|
|
321
321
|
root = prepare_env()
|
|
322
|
-
if create_db_if_missing:
|
|
322
|
+
if create_db_if_missing and resolved_url:
|
|
323
323
|
ensure_database_exists(resolved_url)
|
|
324
324
|
|
|
325
325
|
mig_dir = init_alembic(
|
svc_infra/db/sql/management.py
CHANGED
|
@@ -15,20 +15,19 @@ def _sa_columns(model: type[object]) -> list[Column]:
|
|
|
15
15
|
def _py_type(col: Column) -> type:
|
|
16
16
|
# Prefer SQLAlchemy-provided python_type when available
|
|
17
17
|
if getattr(col.type, "python_type", None):
|
|
18
|
-
return col.type.python_type
|
|
18
|
+
return col.type.python_type
|
|
19
19
|
|
|
20
20
|
from datetime import date, datetime
|
|
21
|
-
from typing import Any as _Any
|
|
22
21
|
from uuid import UUID
|
|
23
22
|
|
|
24
23
|
from sqlalchemy import JSON, Boolean, Date, DateTime, Integer, String, Text
|
|
25
24
|
|
|
26
25
|
try:
|
|
27
26
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
28
|
-
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
27
|
+
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
29
28
|
except Exception: # pragma: no cover
|
|
30
|
-
PG_UUID = None # type: ignore
|
|
31
|
-
JSONB = None # type: ignore
|
|
29
|
+
PG_UUID = None # type: ignore[misc,assignment]
|
|
30
|
+
JSONB = None # type: ignore[misc,assignment]
|
|
32
31
|
|
|
33
32
|
t = col.type
|
|
34
33
|
if PG_UUID is not None and isinstance(t, PG_UUID):
|
|
@@ -47,7 +46,7 @@ def _py_type(col: Column) -> type:
|
|
|
47
46
|
return dict
|
|
48
47
|
if JSONB is not None and isinstance(t, JSONB):
|
|
49
48
|
return dict
|
|
50
|
-
return
|
|
49
|
+
return object # fallback type for unknown column types
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
def _exclude_from_create(col: Column) -> bool:
|
svc_infra/db/sql/repository.py
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from typing import Any, cast
|
|
4
7
|
|
|
5
8
|
from sqlalchemy import Select, String, and_, func, or_, select
|
|
6
9
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
10
|
from sqlalchemy.orm import InstrumentedAttribute, class_mapper
|
|
8
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _escape_ilike(q: str) -> str:
|
|
16
|
+
"""Escape special characters for ILIKE pattern matching.
|
|
17
|
+
|
|
18
|
+
Prevents SQL injection via wildcard characters that could match
|
|
19
|
+
unintended data (e.g., % matches any string, _ matches any char).
|
|
20
|
+
"""
|
|
21
|
+
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
22
|
+
|
|
9
23
|
|
|
10
24
|
class SqlRepository:
|
|
11
25
|
"""
|
|
@@ -20,22 +34,22 @@ class SqlRepository:
|
|
|
20
34
|
soft_delete: bool = False,
|
|
21
35
|
soft_delete_field: str = "deleted_at",
|
|
22
36
|
soft_delete_flag_field: str | None = None,
|
|
23
|
-
immutable_fields:
|
|
37
|
+
immutable_fields: set[str] | None = None,
|
|
24
38
|
):
|
|
25
39
|
self.model = model
|
|
26
40
|
self.id_attr = id_attr
|
|
27
41
|
self.soft_delete = soft_delete
|
|
28
42
|
self.soft_delete_field = soft_delete_field
|
|
29
43
|
self.soft_delete_flag_field = soft_delete_flag_field
|
|
30
|
-
self.immutable_fields:
|
|
44
|
+
self.immutable_fields: set[str] = set(
|
|
31
45
|
immutable_fields or {"id", "created_at", "updated_at"}
|
|
32
46
|
)
|
|
33
47
|
|
|
34
48
|
def _model_columns(self) -> set[str]:
|
|
35
49
|
return {c.key for c in class_mapper(self.model).columns}
|
|
36
50
|
|
|
37
|
-
def _id_column(self) -> InstrumentedAttribute:
|
|
38
|
-
return getattr(self.model, self.id_attr)
|
|
51
|
+
def _id_column(self) -> InstrumentedAttribute[Any]:
|
|
52
|
+
return cast("InstrumentedAttribute[Any]", getattr(self.model, self.id_attr))
|
|
39
53
|
|
|
40
54
|
def _base_select(self) -> Select:
|
|
41
55
|
stmt = select(self.model)
|
|
@@ -55,21 +69,36 @@ class SqlRepository:
|
|
|
55
69
|
*,
|
|
56
70
|
limit: int,
|
|
57
71
|
offset: int,
|
|
58
|
-
order_by:
|
|
72
|
+
order_by: Sequence[Any] | None = None,
|
|
73
|
+
where: Sequence[Any] | None = None,
|
|
59
74
|
) -> Sequence[Any]:
|
|
60
|
-
stmt = self._base_select()
|
|
75
|
+
stmt = self._base_select()
|
|
76
|
+
if where:
|
|
77
|
+
stmt = stmt.where(and_(*where))
|
|
78
|
+
stmt = stmt.limit(limit).offset(offset)
|
|
61
79
|
if order_by:
|
|
62
80
|
stmt = stmt.order_by(*order_by)
|
|
63
|
-
|
|
64
|
-
return
|
|
81
|
+
result = (await session.execute(stmt)).scalars().all()
|
|
82
|
+
return list(result)
|
|
65
83
|
|
|
66
|
-
async def count(self, session: AsyncSession) -> int:
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
async def count(self, session: AsyncSession, *, where: Sequence[Any] | None = None) -> int:
|
|
85
|
+
base = self._base_select()
|
|
86
|
+
if where:
|
|
87
|
+
base = base.where(and_(*where))
|
|
88
|
+
stmt = select(func.count()).select_from(base.subquery())
|
|
89
|
+
return int((await session.execute(stmt)).scalar_one())
|
|
69
90
|
|
|
70
|
-
async def get(
|
|
91
|
+
async def get(
|
|
92
|
+
self,
|
|
93
|
+
session: AsyncSession,
|
|
94
|
+
id_value: Any,
|
|
95
|
+
*,
|
|
96
|
+
where: Sequence[Any] | None = None,
|
|
97
|
+
) -> Any | None:
|
|
71
98
|
# honors soft-delete if configured
|
|
72
99
|
stmt = self._base_select().where(self._id_column() == id_value)
|
|
100
|
+
if where:
|
|
101
|
+
stmt = stmt.where(and_(*where))
|
|
73
102
|
return (await session.execute(stmt)).scalars().first()
|
|
74
103
|
|
|
75
104
|
async def create(self, session: AsyncSession, data: dict[str, Any]) -> Any:
|
|
@@ -78,12 +107,18 @@ class SqlRepository:
|
|
|
78
107
|
obj = self.model(**filtered)
|
|
79
108
|
session.add(obj)
|
|
80
109
|
await session.flush()
|
|
110
|
+
await session.refresh(obj)
|
|
81
111
|
return obj
|
|
82
112
|
|
|
83
113
|
async def update(
|
|
84
|
-
self,
|
|
114
|
+
self,
|
|
115
|
+
session: AsyncSession,
|
|
116
|
+
id_value: Any,
|
|
117
|
+
data: dict[str, Any],
|
|
118
|
+
*,
|
|
119
|
+
where: Sequence[Any] | None = None,
|
|
85
120
|
) -> Any | None:
|
|
86
|
-
obj = await self.get(session, id_value)
|
|
121
|
+
obj = await self.get(session, id_value, where=where)
|
|
87
122
|
if not obj:
|
|
88
123
|
return None
|
|
89
124
|
valid = self._model_columns()
|
|
@@ -91,21 +126,38 @@ class SqlRepository:
|
|
|
91
126
|
if k in valid and k not in self.immutable_fields:
|
|
92
127
|
setattr(obj, k, v)
|
|
93
128
|
await session.flush()
|
|
129
|
+
await session.refresh(obj)
|
|
94
130
|
return obj
|
|
95
131
|
|
|
96
|
-
async def delete(
|
|
97
|
-
|
|
132
|
+
async def delete(
|
|
133
|
+
self,
|
|
134
|
+
session: AsyncSession,
|
|
135
|
+
id_value: Any,
|
|
136
|
+
*,
|
|
137
|
+
where: Sequence[Any] | None = None,
|
|
138
|
+
) -> bool:
|
|
139
|
+
# Fast path: when no extra filters provided, use session.get for simplicity (matches tests)
|
|
140
|
+
if not where:
|
|
141
|
+
obj = await session.get(self.model, id_value)
|
|
142
|
+
else:
|
|
143
|
+
# Respect soft-delete and optional tenant/extra filters by selecting through base select
|
|
144
|
+
stmt = self._base_select().where(self._id_column() == id_value)
|
|
145
|
+
stmt = stmt.where(and_(*where))
|
|
146
|
+
obj = (await session.execute(stmt)).scalars().first()
|
|
98
147
|
if not obj:
|
|
99
148
|
return False
|
|
100
149
|
if self.soft_delete:
|
|
101
150
|
# Prefer timestamp, also optionally set flag to False
|
|
102
|
-
|
|
151
|
+
# Check attributes on the instance to support test doubles without class-level fields
|
|
152
|
+
if hasattr(obj, self.soft_delete_field):
|
|
103
153
|
setattr(obj, self.soft_delete_field, func.now())
|
|
104
|
-
if self.soft_delete_flag_field and hasattr(
|
|
154
|
+
if self.soft_delete_flag_field and hasattr(obj, self.soft_delete_flag_field):
|
|
105
155
|
setattr(obj, self.soft_delete_flag_field, False)
|
|
106
156
|
await session.flush()
|
|
107
157
|
return True
|
|
108
|
-
session.delete(obj)
|
|
158
|
+
delete_result = session.delete(obj)
|
|
159
|
+
if inspect.isawaitable(delete_result):
|
|
160
|
+
await delete_result
|
|
109
161
|
await session.flush()
|
|
110
162
|
return True
|
|
111
163
|
|
|
@@ -117,19 +169,23 @@ class SqlRepository:
|
|
|
117
169
|
fields: Sequence[str],
|
|
118
170
|
limit: int,
|
|
119
171
|
offset: int,
|
|
120
|
-
order_by:
|
|
172
|
+
order_by: Sequence[Any] | None = None,
|
|
173
|
+
where: Sequence[Any] | None = None,
|
|
121
174
|
) -> Sequence[Any]:
|
|
122
|
-
ilike = f"%{q}%"
|
|
175
|
+
ilike = f"%{_escape_ilike(q)}%"
|
|
123
176
|
conditions = []
|
|
124
177
|
for f in fields:
|
|
125
178
|
col = getattr(self.model, f, None)
|
|
126
179
|
if col is not None:
|
|
127
180
|
try:
|
|
128
181
|
conditions.append(col.cast(String).ilike(ilike))
|
|
129
|
-
except Exception:
|
|
182
|
+
except Exception as e:
|
|
130
183
|
# skip columns that cannot be used in ilike even with cast
|
|
184
|
+
logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
|
|
131
185
|
continue
|
|
132
186
|
stmt = self._base_select()
|
|
187
|
+
if where:
|
|
188
|
+
stmt = stmt.where(and_(*where))
|
|
133
189
|
if conditions:
|
|
134
190
|
stmt = stmt.where(or_(*conditions))
|
|
135
191
|
stmt = stmt.limit(limit).offset(offset)
|
|
@@ -137,17 +193,27 @@ class SqlRepository:
|
|
|
137
193
|
stmt = stmt.order_by(*order_by)
|
|
138
194
|
return (await session.execute(stmt)).scalars().all()
|
|
139
195
|
|
|
140
|
-
async def count_filtered(
|
|
141
|
-
|
|
196
|
+
async def count_filtered(
|
|
197
|
+
self,
|
|
198
|
+
session: AsyncSession,
|
|
199
|
+
*,
|
|
200
|
+
q: str,
|
|
201
|
+
fields: Sequence[str],
|
|
202
|
+
where: Sequence[Any] | None = None,
|
|
203
|
+
) -> int:
|
|
204
|
+
ilike = f"%{_escape_ilike(q)}%"
|
|
142
205
|
conditions = []
|
|
143
206
|
for f in fields:
|
|
144
207
|
col = getattr(self.model, f, None)
|
|
145
208
|
if col is not None:
|
|
146
209
|
try:
|
|
147
210
|
conditions.append(col.cast(String).ilike(ilike))
|
|
148
|
-
except Exception:
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.debug("Column %s cannot be cast for ILIKE search: %s", f, e)
|
|
149
213
|
continue
|
|
150
214
|
stmt = self._base_select()
|
|
215
|
+
if where:
|
|
216
|
+
stmt = stmt.where(and_(*where))
|
|
151
217
|
if conditions:
|
|
152
218
|
stmt = stmt.where(or_(*conditions))
|
|
153
219
|
# SELECT COUNT(*) FROM (<stmt>) as t
|
svc_infra/db/sql/resource.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from dataclasses import dataclass
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
from svc_infra.db.sql.repository import SqlRepository
|
|
7
8
|
|
|
@@ -14,23 +15,28 @@ if TYPE_CHECKING:
|
|
|
14
15
|
class SqlResource:
|
|
15
16
|
model: type[object]
|
|
16
17
|
prefix: str
|
|
17
|
-
tags:
|
|
18
|
+
tags: list[str] | None = None
|
|
18
19
|
|
|
19
20
|
id_attr: str = "id"
|
|
20
21
|
soft_delete: bool = False
|
|
21
|
-
search_fields:
|
|
22
|
-
ordering_default:
|
|
23
|
-
allowed_order_fields:
|
|
22
|
+
search_fields: list[str] | None = None
|
|
23
|
+
ordering_default: str | None = None
|
|
24
|
+
allowed_order_fields: list[str] | None = None
|
|
24
25
|
|
|
25
|
-
read_schema:
|
|
26
|
-
create_schema:
|
|
27
|
-
update_schema:
|
|
26
|
+
read_schema: type | None = None
|
|
27
|
+
create_schema: type | None = None
|
|
28
|
+
update_schema: type | None = None
|
|
28
29
|
|
|
29
|
-
read_name:
|
|
30
|
-
create_name:
|
|
31
|
-
update_name:
|
|
30
|
+
read_name: str | None = None
|
|
31
|
+
create_name: str | None = None
|
|
32
|
+
update_name: str | None = None
|
|
32
33
|
|
|
33
34
|
create_exclude: tuple[str, ...] = ("id",)
|
|
34
35
|
|
|
35
36
|
# Only a type reference; no runtime dependency on FastAPI layer
|
|
36
|
-
service_factory:
|
|
37
|
+
service_factory: Callable[[SqlRepository], SqlService] | None = None
|
|
38
|
+
|
|
39
|
+
# Tenancy
|
|
40
|
+
tenant_field: str | None = (
|
|
41
|
+
None # when set, CRUD router will require TenantId and scope by field
|
|
42
|
+
)
|
svc_infra/db/sql/scaffold.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Any,
|
|
5
|
+
from typing import Any, Literal
|
|
6
6
|
|
|
7
7
|
from svc_infra.db.utils import normalize_dir, pascal, plural_snake, snake
|
|
8
8
|
from svc_infra.utils import ensure_init_py, render_template, write
|
|
@@ -13,7 +13,7 @@ _INIT_CONTENT_PAIRED = 'from . import models, schemas\n\n__all__ = ["models", "s
|
|
|
13
13
|
_INIT_CONTENT_MINIMAL = "# package marker; add explicit exports here if desired\n"
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) ->
|
|
16
|
+
def _ensure_init_py(dir_path: Path, overwrite: bool, paired: bool) -> dict[str, Any]:
|
|
17
17
|
"""Create __init__.py; paired=True writes models/schemas re-exports, otherwise minimal."""
|
|
18
18
|
content = _INIT_CONTENT_PAIRED if paired else _INIT_CONTENT_MINIMAL
|
|
19
19
|
return ensure_init_py(dir_path, overwrite, paired, content)
|
|
@@ -31,14 +31,14 @@ def scaffold_core(
|
|
|
31
31
|
schemas_dir: Path | str,
|
|
32
32
|
kind: Kind = "entity",
|
|
33
33
|
entity_name: str = "Item",
|
|
34
|
-
table_name:
|
|
34
|
+
table_name: str | None = None,
|
|
35
35
|
include_tenant: bool = True,
|
|
36
36
|
include_soft_delete: bool = False,
|
|
37
37
|
overwrite: bool = False,
|
|
38
38
|
same_dir: bool = False,
|
|
39
|
-
models_filename:
|
|
40
|
-
schemas_filename:
|
|
41
|
-
) ->
|
|
39
|
+
models_filename: str | None = None,
|
|
40
|
+
schemas_filename: str | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
42
|
"""
|
|
43
43
|
Create starter model + schema files.
|
|
44
44
|
|
|
@@ -150,12 +150,12 @@ def scaffold_models_core(
|
|
|
150
150
|
dest_dir: Path | str,
|
|
151
151
|
kind: Kind = "entity",
|
|
152
152
|
entity_name: str = "Item",
|
|
153
|
-
table_name:
|
|
153
|
+
table_name: str | None = None,
|
|
154
154
|
include_tenant: bool = True,
|
|
155
155
|
include_soft_delete: bool = False,
|
|
156
156
|
overwrite: bool = False,
|
|
157
|
-
models_filename:
|
|
158
|
-
) ->
|
|
157
|
+
models_filename: str | None = None, # <--- NEW
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
159
|
"""Create only a model file (defaults to <snake(entity)>.py unless models_filename is provided)."""
|
|
160
160
|
dest = normalize_dir(dest_dir)
|
|
161
161
|
|
|
@@ -216,8 +216,8 @@ def scaffold_schemas_core(
|
|
|
216
216
|
entity_name: str = "Item",
|
|
217
217
|
include_tenant: bool = True,
|
|
218
218
|
overwrite: bool = False,
|
|
219
|
-
schemas_filename:
|
|
220
|
-
) ->
|
|
219
|
+
schemas_filename: str | None = None, # <--- NEW
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
221
|
"""Create only a schema file (defaults to <snake(entity)>.py unless schemas_filename is provided)."""
|
|
222
222
|
dest = normalize_dir(dest_dir)
|
|
223
223
|
|
svc_infra/db/sql/service.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
2
3
|
|
|
3
4
|
from svc_infra.db.sql.service import SqlService
|
|
4
5
|
|
|
@@ -9,8 +10,8 @@ class SqlServiceWithHooks(SqlService):
|
|
|
9
10
|
def __init__(
|
|
10
11
|
self,
|
|
11
12
|
repo,
|
|
12
|
-
pre_create:
|
|
13
|
-
pre_update:
|
|
13
|
+
pre_create: PreHook | None = None,
|
|
14
|
+
pre_update: PreHook | None = None,
|
|
14
15
|
):
|
|
15
16
|
super().__init__(repo)
|
|
16
17
|
self._pre_create = pre_create
|
|
@@ -129,62 +129,13 @@ for _ix in make_unique_sql_indexes(
|
|
|
129
129
|
# Registered with Table metadata (alembic/autogenerate will pick them up)
|
|
130
130
|
pass
|
|
131
131
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
class
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
- Optionally stores tokens for later API calls (refresh_token encrypted at rest)
|
|
140
|
-
"""
|
|
141
|
-
__tablename__ = "provider_accounts"
|
|
142
|
-
|
|
143
|
-
id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
|
|
144
|
-
|
|
145
|
-
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
146
|
-
GUID(), ForeignKey("${auth_table_name}.id", ondelete="CASCADE"), nullable=False
|
|
147
|
-
)
|
|
148
|
-
user: Mapped["${AuthEntity}"] = relationship(
|
|
149
|
-
back_populates="provider_accounts",
|
|
150
|
-
lazy="selectin",
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
provider: Mapped[str] = mapped_column(String(50), nullable=False) # "google"|"github"|"linkedin"|"microsoft"|...
|
|
154
|
-
provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False) # sub/oid (OIDC) or id (github/linkedin)
|
|
155
|
-
|
|
156
|
-
# Optional token material
|
|
157
|
-
access_token: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
158
|
-
|
|
159
|
-
# Store encrypted refresh_token in the same column name for DB compatibility.
|
|
160
|
-
_refresh_token: Mapped[Optional[str]] = mapped_column("refresh_token", Text, nullable=True)
|
|
161
|
-
|
|
162
|
-
@property
|
|
163
|
-
def refresh_token(self) -> Optional[str]:
|
|
164
|
-
return _decrypt(self._refresh_token)
|
|
165
|
-
|
|
166
|
-
@refresh_token.setter
|
|
167
|
-
def refresh_token(self, value: Optional[str]) -> None:
|
|
168
|
-
self._refresh_token = _encrypt(value)
|
|
169
|
-
|
|
170
|
-
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
171
|
-
raw_claims: Mapped[Optional[dict]] = mapped_column(MutableDict.as_mutable(JSON), nullable=True)
|
|
172
|
-
|
|
173
|
-
created_at = mapped_column(
|
|
174
|
-
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
|
|
175
|
-
)
|
|
176
|
-
updated_at = mapped_column(
|
|
177
|
-
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"),
|
|
178
|
-
onupdate=text("CURRENT_TIMESTAMP"), nullable=False
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
__table_args__ = (
|
|
182
|
-
UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
|
|
183
|
-
Index("ix_provider_accounts_user_id", "user_id"),
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
def __repr__(self) -> str:
|
|
187
|
-
return f"<ProviderAccount provider={self.provider!r} provider_account_id={self.provider_account_id!r} user_id={self.user_id}>"
|
|
132
|
+
# NOTE: ProviderAccount model is imported from svc_infra.security.oauth_models
|
|
133
|
+
# It's an opt-in OAuth model that links users to providers (Google, GitHub, etc.)
|
|
134
|
+
# The relationship 'provider_accounts' is defined above in the ${AuthEntity} class.
|
|
135
|
+
# To enable OAuth in your project:
|
|
136
|
+
# 1. Set ALEMBIC_ENABLE_OAUTH=true in your .env
|
|
137
|
+
# 2. Pass provider_account_model=ProviderAccount to add_auth_users()
|
|
138
|
+
# 3. Import: from svc_infra.security.oauth_models import ProviderAccount
|
|
188
139
|
|
|
189
140
|
# --- Auth service factory ------------------------------------------------------
|
|
190
141
|
|
|
@@ -6,13 +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
|
-
from sqlalchemy.ext.asyncio import create_async_engine
|
|
11
11
|
|
|
12
|
-
from svc_infra.db.sql.utils import
|
|
13
|
-
get_database_url_from_env,
|
|
14
|
-
_ensure_ssl_default_async as _ensure_ssl_default,
|
|
15
|
-
)
|
|
12
|
+
from svc_infra.db.sql.utils import get_database_url_from_env
|
|
16
13
|
|
|
17
14
|
try:
|
|
18
15
|
from svc_infra.db.sql.types import GUID as _GUID # type: ignore
|
|
@@ -105,7 +102,6 @@ def _coerce_to_async(u: URL) -> URL:
|
|
|
105
102
|
|
|
106
103
|
u = make_url(effective_url)
|
|
107
104
|
u = _coerce_to_async(u)
|
|
108
|
-
u = _ensure_ssl_default(u)
|
|
109
105
|
config.set_main_option("sqlalchemy.url", u.render_as_string(hide_password=False))
|
|
110
106
|
|
|
111
107
|
# feature flags
|
|
@@ -131,15 +127,16 @@ def _collect_metadata() -> list[object]:
|
|
|
131
127
|
|
|
132
128
|
def _maybe_add(obj: object) -> None:
|
|
133
129
|
md = getattr(obj, "metadata", None) or obj
|
|
134
|
-
|
|
130
|
+
# Strict check: must be actual MetaData instance
|
|
131
|
+
if isinstance(md, MetaData) and md.tables:
|
|
135
132
|
found.append(md)
|
|
136
133
|
|
|
137
134
|
def _scan_module_objects(mod: object) -> None:
|
|
138
135
|
try:
|
|
139
136
|
for val in vars(mod).values():
|
|
140
|
-
|
|
141
|
-
if
|
|
142
|
-
found.append(
|
|
137
|
+
# Strict check: must be actual MetaData instance
|
|
138
|
+
if isinstance(val, MetaData) and val.tables:
|
|
139
|
+
found.append(val)
|
|
143
140
|
except Exception:
|
|
144
141
|
pass
|
|
145
142
|
|
|
@@ -177,8 +174,16 @@ def _collect_metadata() -> list[object]:
|
|
|
177
174
|
if name not in pkgs:
|
|
178
175
|
pkgs.append(name)
|
|
179
176
|
|
|
177
|
+
# Only attempt bare 'models' import if it is discoverable to avoid noisy tracebacks
|
|
180
178
|
if "models" not in pkgs:
|
|
181
|
-
|
|
179
|
+
try:
|
|
180
|
+
spec = getattr(importlib, "util", None)
|
|
181
|
+
if spec is not None and getattr(spec, "find_spec", None) is not None:
|
|
182
|
+
if spec.find_spec("models") is not None:
|
|
183
|
+
pkgs.append("models")
|
|
184
|
+
except Exception:
|
|
185
|
+
# Best-effort; if discovery fails, skip adding bare 'models'
|
|
186
|
+
pass
|
|
182
187
|
|
|
183
188
|
def _import_and_collect(modname: str):
|
|
184
189
|
try:
|
|
@@ -221,6 +226,21 @@ def _collect_metadata() -> list[object]:
|
|
|
221
226
|
except Exception:
|
|
222
227
|
_note("ModelBase import", False, traceback.format_exc())
|
|
223
228
|
|
|
229
|
+
# Core security models (AuthSession, RefreshToken, etc.)
|
|
230
|
+
try:
|
|
231
|
+
import svc_infra.security.models # noqa: F401
|
|
232
|
+
_note("svc_infra.security.models", True, None)
|
|
233
|
+
except Exception:
|
|
234
|
+
_note("svc_infra.security.models", False, traceback.format_exc())
|
|
235
|
+
|
|
236
|
+
# OAuth models (opt-in via environment variable)
|
|
237
|
+
if os.getenv("ALEMBIC_ENABLE_OAUTH", "").lower() in {"1", "true", "yes"}:
|
|
238
|
+
try:
|
|
239
|
+
import svc_infra.security.oauth_models # noqa: F401
|
|
240
|
+
_note("svc_infra.security.oauth_models", True, None)
|
|
241
|
+
except Exception:
|
|
242
|
+
_note("svc_infra.security.oauth_models", False, traceback.format_exc())
|
|
243
|
+
|
|
224
244
|
try:
|
|
225
245
|
from svc_infra.db.sql.apikey import try_autobind_apikey_model
|
|
226
246
|
try_autobind_apikey_model(require_env=False)
|
|
@@ -352,7 +372,9 @@ def _do_run_migrations(connection):
|
|
|
352
372
|
|
|
353
373
|
async def run_migrations_online() -> None:
|
|
354
374
|
url = config.get_main_option("sqlalchemy.url")
|
|
355
|
-
|
|
375
|
+
# Use build_engine to ensure proper driver-specific handling (e.g., asyncpg SSL)
|
|
376
|
+
from svc_infra.db.sql.utils import build_engine
|
|
377
|
+
engine = build_engine(url)
|
|
356
378
|
async with engine.connect() as connection:
|
|
357
379
|
await connection.run_sync(_do_run_migrations)
|
|
358
380
|
await engine.dispose()
|