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/sql/utils.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import TYPE_CHECKING, Any, Optional, Sequence, Set, Union
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Optional, Sequence, Set, Union, cast
|
|
7
7
|
|
|
8
8
|
from alembic.config import Config
|
|
9
9
|
from dotenv import load_dotenv
|
|
@@ -17,19 +17,19 @@ if TYPE_CHECKING:
|
|
|
17
17
|
from sqlalchemy.engine import Engine as SyncEngine
|
|
18
18
|
from sqlalchemy.ext.asyncio import AsyncEngine as AsyncEngineType
|
|
19
19
|
else:
|
|
20
|
-
SyncEngine = Any # type: ignore
|
|
21
|
-
AsyncEngineType = Any # type: ignore
|
|
20
|
+
SyncEngine = Any # type: ignore[assignment]
|
|
21
|
+
AsyncEngineType = Any # type: ignore[assignment]
|
|
22
22
|
|
|
23
23
|
try:
|
|
24
24
|
# Runtime import (may be missing if async extras aren’t installed)
|
|
25
|
-
from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
|
|
25
|
+
from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
|
|
26
26
|
except Exception: # pragma: no cover - optional dep
|
|
27
|
-
_create_async_engine = None # type: ignore
|
|
27
|
+
_create_async_engine = None # type: ignore[assignment]
|
|
28
28
|
|
|
29
29
|
try:
|
|
30
|
-
from sqlalchemy import create_engine as _create_engine
|
|
30
|
+
from sqlalchemy import create_engine as _create_engine
|
|
31
31
|
except Exception: # pragma: no cover - optional env
|
|
32
|
-
_create_engine = None # type: ignore
|
|
32
|
+
_create_engine = None # type: ignore[assignment]
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def prepare_process_env(
|
|
@@ -83,7 +83,9 @@ def _compose_url_from_parts() -> Optional[str]:
|
|
|
83
83
|
DB_PARAMS (raw query string like 'sslmode=require&connect_timeout=5')
|
|
84
84
|
"""
|
|
85
85
|
dialect = os.getenv("DB_DIALECT", "").strip() or "postgresql"
|
|
86
|
-
driver = os.getenv(
|
|
86
|
+
driver = os.getenv(
|
|
87
|
+
"DB_DRIVER", ""
|
|
88
|
+
).strip() # e.g. asyncpg, psycopg, pymysql, aiosqlite
|
|
87
89
|
host = os.getenv("DB_HOST", "").strip() or None
|
|
88
90
|
port = os.getenv("DB_PORT", "").strip() or None
|
|
89
91
|
db = os.getenv("DB_NAME", "").strip() or None
|
|
@@ -124,18 +126,62 @@ def _compose_url_from_parts() -> Optional[str]:
|
|
|
124
126
|
# ---------- Environment helpers ----------
|
|
125
127
|
|
|
126
128
|
|
|
129
|
+
def _normalize_database_url(url: str) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Normalize database URL for SQLAlchemy compatibility.
|
|
132
|
+
|
|
133
|
+
Handles:
|
|
134
|
+
- postgres:// → postgresql:// (Heroku/Railway legacy format)
|
|
135
|
+
- Strips whitespace
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
url: Raw database URL string
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Normalized URL suitable for SQLAlchemy
|
|
142
|
+
"""
|
|
143
|
+
url = url.strip()
|
|
144
|
+
# Heroku and Railway historically use 'postgres://' which SQLAlchemy doesn't accept
|
|
145
|
+
if url.startswith("postgres://"):
|
|
146
|
+
url = "postgresql://" + url[len("postgres://") :]
|
|
147
|
+
return url
|
|
148
|
+
|
|
149
|
+
|
|
127
150
|
def get_database_url_from_env(
|
|
128
151
|
required: bool = True,
|
|
129
152
|
env_vars: Sequence[str] = DEFAULT_DB_ENV_VARS,
|
|
153
|
+
normalize: bool = True,
|
|
130
154
|
) -> Optional[str]:
|
|
131
155
|
"""
|
|
132
156
|
Resolve the database connection string, with support for:
|
|
133
|
-
- Primary env vars (in order): DEFAULT_DB_ENV_VARS
|
|
157
|
+
- Primary env vars (in order): DEFAULT_DB_ENV_VARS
|
|
158
|
+
(SQL_URL, DB_URL, DATABASE_URL, DATABASE_URL_PRIVATE, PRIVATE_SQL_URL)
|
|
134
159
|
- Secret file pointers: <NAME>_FILE (reads file contents).
|
|
135
160
|
- Well-known locations: SQL_URL_FILE, /run/secrets/database_url.
|
|
136
161
|
- Composed from parts: DB_* (host, port, name, user, password, params).
|
|
137
|
-
|
|
162
|
+
|
|
163
|
+
When a value is found, it is also written back into os.environ["SQL_URL"]
|
|
164
|
+
for downstream code.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
required: If True, raise RuntimeError when no URL is found
|
|
168
|
+
env_vars: Sequence of environment variable names to check
|
|
169
|
+
normalize: If True, convert postgres:// to postgresql:// (default: True)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Database URL string, or None if not found and not required
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
RuntimeError: If required=True and no URL is found
|
|
138
176
|
"""
|
|
177
|
+
|
|
178
|
+
def _finalize(url: str) -> str:
|
|
179
|
+
"""Normalize and cache the URL."""
|
|
180
|
+
if normalize:
|
|
181
|
+
url = _normalize_database_url(url)
|
|
182
|
+
os.environ["SQL_URL"] = url
|
|
183
|
+
return url
|
|
184
|
+
|
|
139
185
|
# Load .env without clobbering existing process env
|
|
140
186
|
load_dotenv(override=False)
|
|
141
187
|
|
|
@@ -150,10 +196,8 @@ def get_database_url_from_env(
|
|
|
150
196
|
if os.path.isabs(s) and Path(s).exists():
|
|
151
197
|
file_val = _read_secret_from_file(s)
|
|
152
198
|
if file_val:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
os.environ["SQL_URL"] = s
|
|
156
|
-
return s
|
|
199
|
+
return _finalize(file_val)
|
|
200
|
+
return _finalize(s)
|
|
157
201
|
|
|
158
202
|
# Companion NAME_FILE secret path (e.g., SQL_URL_FILE)
|
|
159
203
|
file_key = f"{key}_FILE"
|
|
@@ -161,32 +205,28 @@ def get_database_url_from_env(
|
|
|
161
205
|
if file_path:
|
|
162
206
|
file_val = _read_secret_from_file(file_path)
|
|
163
207
|
if file_val:
|
|
164
|
-
|
|
165
|
-
return file_val
|
|
208
|
+
return _finalize(file_val)
|
|
166
209
|
|
|
167
210
|
# 2) Conventional secret envs
|
|
168
211
|
file_path = os.getenv("SQL_URL_FILE")
|
|
169
212
|
if file_path:
|
|
170
213
|
file_val = _read_secret_from_file(file_path)
|
|
171
214
|
if file_val:
|
|
172
|
-
|
|
173
|
-
return file_val
|
|
215
|
+
return _finalize(file_val)
|
|
174
216
|
|
|
175
217
|
# 3) Docker/K8s default secret mount
|
|
176
218
|
file_val = _read_secret_from_file("/run/secrets/database_url")
|
|
177
219
|
if file_val:
|
|
178
|
-
|
|
179
|
-
return file_val
|
|
220
|
+
return _finalize(file_val)
|
|
180
221
|
|
|
181
222
|
# 4) Compose from parts (DB_DIALECT/DB_DRIVER/DB_HOST/.../DB_PARAMS)
|
|
182
223
|
composed = _compose_url_from_parts()
|
|
183
224
|
if composed:
|
|
184
|
-
|
|
185
|
-
return composed
|
|
225
|
+
return _finalize(composed)
|
|
186
226
|
|
|
187
227
|
if required:
|
|
188
228
|
raise RuntimeError(
|
|
189
|
-
"Database URL not set. Set SQL_URL
|
|
229
|
+
"Database URL not set. Set SQL_URL, DATABASE_URL, or DATABASE_URL_PRIVATE, "
|
|
190
230
|
"or provide DB_* parts (DB_HOST, DB_NAME, etc.), or a *_FILE secret."
|
|
191
231
|
)
|
|
192
232
|
return None
|
|
@@ -196,10 +236,17 @@ def _ensure_timeout_default(u: URL) -> URL:
|
|
|
196
236
|
"""
|
|
197
237
|
Ensure a conservative connection timeout is present for libpq-based drivers.
|
|
198
238
|
For psycopg/psycopg2, 'connect_timeout' is honored via the query string.
|
|
239
|
+
For asyncpg, timeout is set via connect_args (not query string).
|
|
199
240
|
"""
|
|
200
241
|
backend = (u.get_backend_name() or "").lower()
|
|
201
242
|
if backend not in ("postgresql", "postgres"):
|
|
202
243
|
return u
|
|
244
|
+
|
|
245
|
+
# asyncpg doesn't support connect_timeout in query string - use connect_args instead
|
|
246
|
+
dn = (u.drivername or "").lower()
|
|
247
|
+
if "+asyncpg" in dn:
|
|
248
|
+
return u
|
|
249
|
+
|
|
203
250
|
if "connect_timeout" in u.query:
|
|
204
251
|
return u
|
|
205
252
|
# Default 10s unless overridden
|
|
@@ -329,17 +376,22 @@ def _ensure_ssl_default(u: URL) -> URL:
|
|
|
329
376
|
driver = (u.drivername or "").lower()
|
|
330
377
|
|
|
331
378
|
# If any SSL hint already present, do nothing
|
|
332
|
-
if any(
|
|
379
|
+
if any(
|
|
380
|
+
k in u.query for k in ("sslmode", "ssl", "sslrootcert", "sslcert", "sslkey")
|
|
381
|
+
):
|
|
333
382
|
return u
|
|
334
383
|
|
|
335
384
|
# Allow env override; support both common spellings
|
|
336
|
-
mode_env =
|
|
385
|
+
mode_env = (
|
|
386
|
+
os.getenv("DB_SSLMODE_DEFAULT")
|
|
387
|
+
or os.getenv("PGSSLMODE")
|
|
388
|
+
or os.getenv("PGSSL_MODE")
|
|
389
|
+
)
|
|
337
390
|
mode = (mode_env or "").strip()
|
|
338
391
|
|
|
339
392
|
if "+asyncpg" in driver:
|
|
340
|
-
# asyncpg:
|
|
341
|
-
|
|
342
|
-
return u.set(query={**u.query, "ssl": "true"})
|
|
393
|
+
# asyncpg: SSL is handled in connect_args in build_engine(), not in URL query
|
|
394
|
+
# Do not add ssl parameter to URL query for asyncpg
|
|
343
395
|
return u
|
|
344
396
|
else:
|
|
345
397
|
# libpq-based drivers: use sslmode (default 'require' for hosted PG)
|
|
@@ -351,7 +403,9 @@ def _ensure_ssl_default_async(u: URL) -> URL:
|
|
|
351
403
|
backend = (u.get_backend_name() or "").lower()
|
|
352
404
|
if backend in ("postgresql", "postgres"):
|
|
353
405
|
# asyncpg prefers 'ssl=true' via SQLAlchemy param; if already present, keep it
|
|
354
|
-
if any(
|
|
406
|
+
if any(
|
|
407
|
+
k in u.query for k in ("ssl", "sslmode", "sslrootcert", "sslcert", "sslkey")
|
|
408
|
+
):
|
|
355
409
|
return u
|
|
356
410
|
return u.set(query={**u.query, "ssl": "true"})
|
|
357
411
|
return u
|
|
@@ -366,7 +420,9 @@ def _certifi_ca() -> str | None:
|
|
|
366
420
|
return None
|
|
367
421
|
|
|
368
422
|
|
|
369
|
-
def build_engine(
|
|
423
|
+
def build_engine(
|
|
424
|
+
url: URL | str, echo: bool = False
|
|
425
|
+
) -> Union[SyncEngine, AsyncEngineType]:
|
|
370
426
|
u = make_url(url) if isinstance(url, str) else url
|
|
371
427
|
|
|
372
428
|
# Keep your existing PG helpers
|
|
@@ -382,10 +438,20 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
382
438
|
"Async driver URL provided but SQLAlchemy async extras are not available."
|
|
383
439
|
)
|
|
384
440
|
|
|
385
|
-
# asyncpg: honor connection timeout
|
|
441
|
+
# asyncpg: honor connection timeout only (NOT connect_timeout)
|
|
386
442
|
if "+asyncpg" in (u.drivername or ""):
|
|
387
443
|
connect_args["timeout"] = int(os.getenv("DB_CONNECT_TIMEOUT", "10"))
|
|
388
444
|
|
|
445
|
+
# asyncpg doesn't accept sslmode or ssl=true in query params
|
|
446
|
+
# Remove these and set ssl='require' in connect_args
|
|
447
|
+
if "ssl" in u.query or "sslmode" in u.query:
|
|
448
|
+
new_query = {
|
|
449
|
+
k: v for k, v in u.query.items() if k not in ("ssl", "sslmode")
|
|
450
|
+
}
|
|
451
|
+
u = u.set(query=new_query)
|
|
452
|
+
# Set ssl in connect_args - 'require' is safest for hosted databases
|
|
453
|
+
connect_args["ssl"] = "require"
|
|
454
|
+
|
|
389
455
|
# NEW: aiomysql SSL default
|
|
390
456
|
if "+aiomysql" in (u.drivername or "") and not any(
|
|
391
457
|
k in u.query for k in ("ssl", "ssl_ca", "sslmode")
|
|
@@ -395,7 +461,11 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
395
461
|
import ssl
|
|
396
462
|
|
|
397
463
|
ca = _certifi_ca()
|
|
398
|
-
ctx =
|
|
464
|
+
ctx = (
|
|
465
|
+
ssl.create_default_context(cafile=ca)
|
|
466
|
+
if ca
|
|
467
|
+
else ssl.create_default_context()
|
|
468
|
+
)
|
|
399
469
|
# if your host uses a public CA, verification works;
|
|
400
470
|
# if not, you can relax verification (not recommended):
|
|
401
471
|
# ctx.check_hostname = False
|
|
@@ -404,15 +474,17 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
404
474
|
except Exception:
|
|
405
475
|
connect_args["ssl"] = True # minimal hint to enable TLS
|
|
406
476
|
|
|
407
|
-
|
|
477
|
+
async_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
|
|
408
478
|
if connect_args:
|
|
409
|
-
|
|
410
|
-
return _create_async_engine(u, **
|
|
479
|
+
async_engine_kwargs["connect_args"] = connect_args
|
|
480
|
+
return _create_async_engine(u, **async_engine_kwargs)
|
|
411
481
|
|
|
412
482
|
# ----------------- SYNC -----------------
|
|
413
483
|
u = _coerce_sync_driver(u)
|
|
414
484
|
if _create_engine is None:
|
|
415
|
-
raise RuntimeError(
|
|
485
|
+
raise RuntimeError(
|
|
486
|
+
"SQLAlchemy create_engine is not available in this environment."
|
|
487
|
+
)
|
|
416
488
|
|
|
417
489
|
dn = (u.drivername or "").lower()
|
|
418
490
|
|
|
@@ -435,10 +507,10 @@ def build_engine(url: URL | str, echo: bool = False) -> Union[SyncEngine, AsyncE
|
|
|
435
507
|
# Optional: if your provider requires it, you can also add:
|
|
436
508
|
# connect_args.setdefault("client_flag", 0)
|
|
437
509
|
|
|
438
|
-
|
|
510
|
+
sync_engine_kwargs: dict[str, Any] = {"echo": echo, "pool_pre_ping": True}
|
|
439
511
|
if connect_args:
|
|
440
|
-
|
|
441
|
-
return _create_engine(u, **
|
|
512
|
+
sync_engine_kwargs["connect_args"] = connect_args
|
|
513
|
+
return _create_engine(u, **sync_engine_kwargs)
|
|
442
514
|
|
|
443
515
|
|
|
444
516
|
# ---------- Identifier quoting helpers ----------
|
|
@@ -485,7 +557,7 @@ async def _pg_create_database_async(url: URL) -> None:
|
|
|
485
557
|
)
|
|
486
558
|
if not exists:
|
|
487
559
|
quoted = _pg_quote_ident(target_db)
|
|
488
|
-
await conn.execution_options(isolation_level="AUTOCOMMIT").execute(
|
|
560
|
+
await conn.execution_options(isolation_level="AUTOCOMMIT").execute( # type: ignore[attr-defined]
|
|
489
561
|
text(f'CREATE DATABASE "{quoted}"')
|
|
490
562
|
)
|
|
491
563
|
except DBAPIError as e:
|
|
@@ -545,7 +617,9 @@ async def _mysql_create_database_async(url: URL) -> None:
|
|
|
545
617
|
engine: AsyncEngineType = build_engine(base_url) # type: ignore[assignment]
|
|
546
618
|
async with engine.begin() as conn:
|
|
547
619
|
exists = await conn.scalar(
|
|
548
|
-
text(
|
|
620
|
+
text(
|
|
621
|
+
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
|
|
622
|
+
),
|
|
549
623
|
{"name": target_db},
|
|
550
624
|
)
|
|
551
625
|
if not exists:
|
|
@@ -562,7 +636,9 @@ def _mysql_create_database_sync(url: URL) -> None:
|
|
|
562
636
|
engine: SyncEngine = build_engine(base_url) # type: ignore[assignment]
|
|
563
637
|
with engine.begin() as conn:
|
|
564
638
|
exists = conn.scalar(
|
|
565
|
-
text(
|
|
639
|
+
text(
|
|
640
|
+
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :name"
|
|
641
|
+
),
|
|
566
642
|
{"name": target_db},
|
|
567
643
|
)
|
|
568
644
|
if not exists:
|
|
@@ -789,17 +865,19 @@ def ensure_database_exists(url: URL | str) -> None:
|
|
|
789
865
|
try:
|
|
790
866
|
eng = build_engine(u)
|
|
791
867
|
if is_async_url(u):
|
|
868
|
+
async_eng = cast(AsyncEngineType, eng)
|
|
792
869
|
|
|
793
870
|
async def _ping_and_dispose():
|
|
794
|
-
async with
|
|
871
|
+
async with async_eng.begin() as conn:
|
|
795
872
|
await conn.execute(text("SELECT 1"))
|
|
796
|
-
await
|
|
873
|
+
await async_eng.dispose()
|
|
797
874
|
|
|
798
875
|
asyncio.run(_ping_and_dispose())
|
|
799
876
|
else:
|
|
800
|
-
|
|
877
|
+
sync_eng = cast(SyncEngine, eng)
|
|
878
|
+
with sync_eng.begin() as conn:
|
|
801
879
|
conn.execute(text("SELECT 1"))
|
|
802
|
-
|
|
880
|
+
sync_eng.dispose()
|
|
803
881
|
except OperationalError as exc: # pragma: no cover (depends on env)
|
|
804
882
|
raise RuntimeError(f"Failed to connect to database: {exc}") from exc
|
|
805
883
|
|
|
@@ -811,7 +889,10 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
811
889
|
return
|
|
812
890
|
|
|
813
891
|
# Gather local revision ids from versions/
|
|
814
|
-
|
|
892
|
+
script_location_str = cfg.get_main_option("script_location")
|
|
893
|
+
if not script_location_str:
|
|
894
|
+
return
|
|
895
|
+
script_location = Path(script_location_str)
|
|
815
896
|
versions_dir = script_location / "versions"
|
|
816
897
|
local_ids: Set[str] = set()
|
|
817
898
|
if versions_dir.exists():
|
|
@@ -831,7 +912,7 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
831
912
|
if is_async_url(url_obj):
|
|
832
913
|
|
|
833
914
|
async def _run() -> None:
|
|
834
|
-
eng = build_engine(url_obj)
|
|
915
|
+
eng = cast(AsyncEngineType, build_engine(url_obj))
|
|
835
916
|
try:
|
|
836
917
|
async with eng.begin() as conn:
|
|
837
918
|
# Do sync-y inspector / SQL via run_sync
|
|
@@ -846,7 +927,9 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
846
927
|
)
|
|
847
928
|
missing = any((ver not in local_ids) for (ver,) in rows)
|
|
848
929
|
if missing:
|
|
849
|
-
sync_conn.execute(
|
|
930
|
+
sync_conn.execute(
|
|
931
|
+
text("DROP TABLE IF EXISTS alembic_version")
|
|
932
|
+
)
|
|
850
933
|
|
|
851
934
|
await conn.run_sync(_check_and_maybe_drop)
|
|
852
935
|
finally:
|
|
@@ -854,13 +937,17 @@ def repair_alembic_state_if_needed(cfg: Config) -> None:
|
|
|
854
937
|
|
|
855
938
|
asyncio.run(_run())
|
|
856
939
|
else:
|
|
857
|
-
eng = build_engine(url_obj)
|
|
940
|
+
eng = cast(SyncEngine, build_engine(url_obj))
|
|
858
941
|
try:
|
|
859
942
|
with eng.begin() as c:
|
|
860
943
|
insp = inspect(c)
|
|
861
944
|
if not insp.has_table("alembic_version"):
|
|
862
945
|
return
|
|
863
|
-
rows = list(
|
|
946
|
+
rows = list(
|
|
947
|
+
c.execute(
|
|
948
|
+
text("SELECT version_num FROM alembic_version")
|
|
949
|
+
).fetchall()
|
|
950
|
+
)
|
|
864
951
|
missing = any((ver not in local_ids) for (ver,) in rows)
|
|
865
952
|
if missing:
|
|
866
953
|
c.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
|
@@ -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)
|