svc-infra 0.1.595__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/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- 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 +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- 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 +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- 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 +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- 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 +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- 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 +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- 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 -57
- 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/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 +3 -4
- 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 +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- 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.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-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/cli/__init__.py
CHANGED
|
@@ -4,10 +4,17 @@ import typer
|
|
|
4
4
|
|
|
5
5
|
from svc_infra.cli.cmds import (
|
|
6
6
|
_HELP,
|
|
7
|
+
jobs_app,
|
|
7
8
|
register_alembic,
|
|
9
|
+
register_db_ops,
|
|
10
|
+
register_docs,
|
|
11
|
+
register_dx,
|
|
12
|
+
register_health,
|
|
8
13
|
register_mongo,
|
|
9
14
|
register_mongo_scaffold,
|
|
10
15
|
register_obs,
|
|
16
|
+
register_sdk,
|
|
17
|
+
register_sql_export,
|
|
11
18
|
register_sql_scaffold,
|
|
12
19
|
)
|
|
13
20
|
from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
@@ -15,16 +22,53 @@ from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
|
15
22
|
app = typer.Typer(no_args_is_help=True, add_completion=False, help=_HELP)
|
|
16
23
|
pre_cli(app)
|
|
17
24
|
|
|
18
|
-
# ---
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
# --- db ops group ---
|
|
26
|
+
db_app = typer.Typer(
|
|
27
|
+
no_args_is_help=True, add_completion=False, help="Database operations"
|
|
28
|
+
)
|
|
29
|
+
register_db_ops(db_app)
|
|
30
|
+
app.add_typer(db_app, name="db")
|
|
31
|
+
|
|
32
|
+
# --- sql group ---
|
|
33
|
+
sql_app = typer.Typer(no_args_is_help=True, add_completion=False, help="SQL commands")
|
|
34
|
+
register_alembic(sql_app)
|
|
35
|
+
register_sql_scaffold(sql_app)
|
|
36
|
+
register_sql_export(sql_app)
|
|
37
|
+
app.add_typer(sql_app, name="sql")
|
|
38
|
+
|
|
39
|
+
# --- mongo group ---
|
|
40
|
+
mongo_app = typer.Typer(
|
|
41
|
+
no_args_is_help=True, add_completion=False, help="MongoDB commands"
|
|
42
|
+
)
|
|
43
|
+
register_mongo(mongo_app)
|
|
44
|
+
register_mongo_scaffold(mongo_app)
|
|
45
|
+
app.add_typer(mongo_app, name="mongo")
|
|
46
|
+
|
|
47
|
+
# --- health group ---
|
|
48
|
+
health_app = typer.Typer(
|
|
49
|
+
no_args_is_help=True, add_completion=False, help="Health checks"
|
|
50
|
+
)
|
|
51
|
+
register_health(health_app)
|
|
52
|
+
app.add_typer(health_app, name="health")
|
|
53
|
+
|
|
54
|
+
# -- obs group ---
|
|
55
|
+
obs_app = typer.Typer(
|
|
56
|
+
no_args_is_help=True, add_completion=False, help="Observability commands"
|
|
57
|
+
)
|
|
58
|
+
register_obs(obs_app)
|
|
59
|
+
app.add_typer(obs_app, name="obs")
|
|
60
|
+
|
|
61
|
+
# -- dx commands ---
|
|
62
|
+
register_dx(app)
|
|
63
|
+
|
|
64
|
+
# -- jobs commands ---
|
|
65
|
+
app.add_typer(jobs_app, name="jobs")
|
|
21
66
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
register_mongo_scaffold(app)
|
|
67
|
+
# -- sdk commands ---
|
|
68
|
+
register_sdk(app)
|
|
25
69
|
|
|
26
|
-
# --
|
|
27
|
-
|
|
70
|
+
# -- docs commands ---
|
|
71
|
+
register_docs(app)
|
|
28
72
|
|
|
29
73
|
|
|
30
74
|
def main():
|
svc_infra/cli/cmds/__init__.py
CHANGED
|
@@ -1,18 +1,55 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from svc_infra.cli.cmds.db.nosql.mongo.mongo_cmds import register as register_mongo
|
|
9
|
+
except ModuleNotFoundError as exc:
|
|
10
|
+
_mongo_import_error = exc
|
|
11
|
+
|
|
12
|
+
def register_mongo(app: typer.Typer) -> None: # type: ignore[no-redef]
|
|
13
|
+
def _unavailable() -> Any:
|
|
14
|
+
raise ModuleNotFoundError(
|
|
15
|
+
"MongoDB CLI commands require optional dependencies. Install pymongo and motor "
|
|
16
|
+
"to enable `svc-infra mongo ...` commands."
|
|
17
|
+
) from _mongo_import_error
|
|
18
|
+
|
|
19
|
+
# Provide a single helpful command instead of failing CLI import.
|
|
20
|
+
app.command("unavailable")(_unavailable)
|
|
21
|
+
|
|
22
|
+
|
|
2
23
|
from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
|
|
3
24
|
register as register_mongo_scaffold,
|
|
4
25
|
)
|
|
26
|
+
from svc_infra.cli.cmds.db.ops_cmds import register as register_db_ops
|
|
5
27
|
from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
|
|
6
|
-
from svc_infra.cli.cmds.db.sql.
|
|
28
|
+
from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
|
|
29
|
+
from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import (
|
|
30
|
+
register as register_sql_scaffold,
|
|
31
|
+
)
|
|
32
|
+
from svc_infra.cli.cmds.docs.docs_cmds import register as register_docs
|
|
33
|
+
from svc_infra.cli.cmds.dx import register_dx
|
|
34
|
+
from svc_infra.cli.cmds.health.health_cmds import register as register_health
|
|
35
|
+
from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
|
|
7
36
|
from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
|
|
37
|
+
from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
|
|
8
38
|
|
|
9
39
|
from .help import _HELP
|
|
10
40
|
|
|
11
41
|
__all__ = [
|
|
12
42
|
"register_alembic",
|
|
13
43
|
"register_sql_scaffold",
|
|
44
|
+
"register_sql_export",
|
|
14
45
|
"register_mongo",
|
|
15
46
|
"register_mongo_scaffold",
|
|
47
|
+
"register_db_ops",
|
|
16
48
|
"register_obs",
|
|
49
|
+
"jobs_app",
|
|
50
|
+
"register_sdk",
|
|
51
|
+
"register_dx",
|
|
52
|
+
"register_docs",
|
|
53
|
+
"register_health",
|
|
17
54
|
"_HELP",
|
|
18
55
|
]
|
|
@@ -172,7 +172,9 @@ def cmd_ping(
|
|
|
172
172
|
|
|
173
173
|
import asyncio
|
|
174
174
|
|
|
175
|
-
from svc_infra.db.nosql.mongo.client import
|
|
175
|
+
from svc_infra.db.nosql.mongo.client import (
|
|
176
|
+
acquire_db,
|
|
177
|
+
) # local import to avoid side effects
|
|
176
178
|
|
|
177
179
|
async def _run():
|
|
178
180
|
await init_mongo()
|
|
@@ -188,6 +190,7 @@ def cmd_ping(
|
|
|
188
190
|
|
|
189
191
|
|
|
190
192
|
def register(app: typer.Typer) -> None:
|
|
191
|
-
app
|
|
192
|
-
app.command("
|
|
193
|
-
app.command("
|
|
193
|
+
# Attach to 'mongo' group app
|
|
194
|
+
app.command("prepare")(cmd_prepare)
|
|
195
|
+
app.command("setup-and-prepare")(cmd_setup_and_prepare)
|
|
196
|
+
app.command("ping")(cmd_ping)
|
|
@@ -17,7 +17,9 @@ def cmd_scaffold(
|
|
|
17
17
|
entity_name: str = typer.Option(
|
|
18
18
|
"Item", help="Entity class name (e.g., User, Member, Product)."
|
|
19
19
|
),
|
|
20
|
-
documents_dir: Path = typer.Option(
|
|
20
|
+
documents_dir: Path = typer.Option(
|
|
21
|
+
..., help="Directory for Mongo document models."
|
|
22
|
+
),
|
|
21
23
|
schemas_dir: Path = typer.Option(..., help="Directory for Pydantic CRUD schemas."),
|
|
22
24
|
overwrite: bool = typer.Option(False, help="Overwrite existing files."),
|
|
23
25
|
same_dir: bool = typer.Option(
|
|
@@ -127,7 +129,7 @@ def register(app: typer.Typer) -> None:
|
|
|
127
129
|
• mongo-scaffold-schemas
|
|
128
130
|
• mongo-scaffold-resources
|
|
129
131
|
"""
|
|
130
|
-
app.command("
|
|
131
|
-
app.command("
|
|
132
|
-
app.command("
|
|
133
|
-
app.command("
|
|
132
|
+
app.command("scaffold")(cmd_scaffold)
|
|
133
|
+
app.command("scaffold-documents")(cmd_scaffold_documents)
|
|
134
|
+
app.command("scaffold-schemas")(cmd_scaffold_schemas)
|
|
135
|
+
app.command("scaffold-resources")(cmd_scaffold_resources)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Database operations CLI commands.
|
|
2
|
+
|
|
3
|
+
Provides CLI commands for database administration:
|
|
4
|
+
- wait: Wait for database to be ready before proceeding
|
|
5
|
+
- kill-queries: Terminate queries blocking a specific table
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_wait(
|
|
19
|
+
database_url: Optional[str] = typer.Option(
|
|
20
|
+
None,
|
|
21
|
+
"--url",
|
|
22
|
+
"-u",
|
|
23
|
+
help="Database URL; overrides env SQL_URL.",
|
|
24
|
+
),
|
|
25
|
+
timeout: int = typer.Option(
|
|
26
|
+
60,
|
|
27
|
+
"--timeout",
|
|
28
|
+
"-t",
|
|
29
|
+
help="Maximum time to wait in seconds.",
|
|
30
|
+
),
|
|
31
|
+
interval: float = typer.Option(
|
|
32
|
+
2.0,
|
|
33
|
+
"--interval",
|
|
34
|
+
"-i",
|
|
35
|
+
help="Time between connection attempts in seconds.",
|
|
36
|
+
),
|
|
37
|
+
quiet: bool = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--quiet",
|
|
40
|
+
"-q",
|
|
41
|
+
help="Suppress progress messages.",
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Wait for database to be ready.
|
|
46
|
+
|
|
47
|
+
Attempts to connect to the database repeatedly until successful
|
|
48
|
+
or timeout is reached. Useful in container startup scripts.
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0: Database is ready
|
|
52
|
+
1: Timeout reached, database not ready
|
|
53
|
+
"""
|
|
54
|
+
url = database_url or os.getenv("SQL_URL") or os.getenv("DATABASE_URL")
|
|
55
|
+
if not url:
|
|
56
|
+
typer.secho(
|
|
57
|
+
"ERROR: No database URL. Set --url, SQL_URL, or DATABASE_URL.",
|
|
58
|
+
fg=typer.colors.RED,
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
async def _wait() -> bool:
|
|
63
|
+
"""Async wait loop."""
|
|
64
|
+
from svc_infra.health import check_database
|
|
65
|
+
|
|
66
|
+
check = check_database(url)
|
|
67
|
+
deadline = time.monotonic() + timeout
|
|
68
|
+
attempt = 0
|
|
69
|
+
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
attempt += 1
|
|
72
|
+
if not quiet:
|
|
73
|
+
typer.echo(f"Attempt {attempt}: Connecting to database...")
|
|
74
|
+
|
|
75
|
+
result = await check()
|
|
76
|
+
|
|
77
|
+
if result.status == "healthy":
|
|
78
|
+
if not quiet:
|
|
79
|
+
typer.secho(
|
|
80
|
+
f"✓ Database ready ({result.latency_ms:.1f}ms)",
|
|
81
|
+
fg=typer.colors.GREEN,
|
|
82
|
+
)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
if not quiet:
|
|
86
|
+
msg = result.message or "Connection failed"
|
|
87
|
+
typer.echo(f" → {msg}")
|
|
88
|
+
|
|
89
|
+
remaining = deadline - time.monotonic()
|
|
90
|
+
if remaining > 0:
|
|
91
|
+
await asyncio.sleep(min(interval, remaining))
|
|
92
|
+
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
success = asyncio.run(_wait())
|
|
96
|
+
if not success:
|
|
97
|
+
typer.secho(
|
|
98
|
+
f"ERROR: Database not ready after {timeout}s",
|
|
99
|
+
fg=typer.colors.RED,
|
|
100
|
+
)
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_kill_queries(
|
|
105
|
+
table: str = typer.Argument(
|
|
106
|
+
...,
|
|
107
|
+
help="Table name to find blocking queries for.",
|
|
108
|
+
),
|
|
109
|
+
database_url: Optional[str] = typer.Option(
|
|
110
|
+
None,
|
|
111
|
+
"--url",
|
|
112
|
+
"-u",
|
|
113
|
+
help="Database URL; overrides env SQL_URL.",
|
|
114
|
+
),
|
|
115
|
+
dry_run: bool = typer.Option(
|
|
116
|
+
False,
|
|
117
|
+
"--dry-run",
|
|
118
|
+
"-n",
|
|
119
|
+
help="Show queries that would be killed without actually killing them.",
|
|
120
|
+
),
|
|
121
|
+
force: bool = typer.Option(
|
|
122
|
+
False,
|
|
123
|
+
"--force",
|
|
124
|
+
"-f",
|
|
125
|
+
help="Terminate immediately (pg_terminate_backend) instead of cancel (pg_cancel_backend).",
|
|
126
|
+
),
|
|
127
|
+
quiet: bool = typer.Option(
|
|
128
|
+
False,
|
|
129
|
+
"--quiet",
|
|
130
|
+
"-q",
|
|
131
|
+
help="Suppress output except errors.",
|
|
132
|
+
),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Kill queries blocking operations on a table.
|
|
136
|
+
|
|
137
|
+
Finds queries that hold locks on the specified table and attempts
|
|
138
|
+
to cancel or terminate them. Useful when migrations are blocked.
|
|
139
|
+
|
|
140
|
+
By default uses pg_cancel_backend (graceful). Use --force for
|
|
141
|
+
pg_terminate_backend (immediate termination).
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
svc-infra db kill-queries users
|
|
145
|
+
svc-infra db kill-queries users --dry-run
|
|
146
|
+
svc-infra db kill-queries users --force
|
|
147
|
+
"""
|
|
148
|
+
url = database_url or os.getenv("SQL_URL") or os.getenv("DATABASE_URL")
|
|
149
|
+
if not url:
|
|
150
|
+
typer.secho(
|
|
151
|
+
"ERROR: No database URL. Set --url, SQL_URL, or DATABASE_URL.",
|
|
152
|
+
fg=typer.colors.RED,
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
async def _kill_queries() -> int:
|
|
157
|
+
"""Find and kill blocking queries. Returns count of killed queries."""
|
|
158
|
+
try:
|
|
159
|
+
import asyncpg
|
|
160
|
+
except ImportError:
|
|
161
|
+
typer.secho(
|
|
162
|
+
"ERROR: asyncpg not installed. Run: pip install asyncpg",
|
|
163
|
+
fg=typer.colors.RED,
|
|
164
|
+
)
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
# Normalize URL for asyncpg
|
|
168
|
+
db_url = url
|
|
169
|
+
if db_url.startswith("postgres://"):
|
|
170
|
+
db_url = db_url.replace("postgres://", "postgresql://", 1)
|
|
171
|
+
if "+asyncpg" in db_url:
|
|
172
|
+
db_url = db_url.replace("+asyncpg", "")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
conn = await asyncpg.connect(db_url)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
typer.secho(
|
|
178
|
+
f"ERROR: Failed to connect to database: {e}",
|
|
179
|
+
fg=typer.colors.RED,
|
|
180
|
+
)
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
# Find queries with locks on the table
|
|
185
|
+
# Uses pg_stat_activity joined with pg_locks to find blocking queries
|
|
186
|
+
find_query = """
|
|
187
|
+
SELECT DISTINCT
|
|
188
|
+
a.pid,
|
|
189
|
+
a.usename,
|
|
190
|
+
a.application_name,
|
|
191
|
+
a.state,
|
|
192
|
+
a.query,
|
|
193
|
+
a.query_start,
|
|
194
|
+
l.locktype,
|
|
195
|
+
l.mode
|
|
196
|
+
FROM pg_stat_activity a
|
|
197
|
+
JOIN pg_locks l ON a.pid = l.pid
|
|
198
|
+
WHERE l.relation = $1::regclass
|
|
199
|
+
AND a.pid != pg_backend_pid()
|
|
200
|
+
ORDER BY a.query_start
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
rows = await conn.fetch(find_query, table)
|
|
205
|
+
except asyncpg.UndefinedTableError:
|
|
206
|
+
typer.secho(
|
|
207
|
+
f"ERROR: Table '{table}' does not exist",
|
|
208
|
+
fg=typer.colors.RED,
|
|
209
|
+
)
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
if not rows:
|
|
213
|
+
if not quiet:
|
|
214
|
+
typer.echo(f"No active queries found on table '{table}'")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
if not quiet:
|
|
218
|
+
typer.echo(f"Found {len(rows)} query(ies) with locks on '{table}':\n")
|
|
219
|
+
for row in rows:
|
|
220
|
+
query_preview = (row["query"] or "")[:80].replace("\n", " ")
|
|
221
|
+
if len(row["query"] or "") > 80:
|
|
222
|
+
query_preview += "..."
|
|
223
|
+
typer.echo(f" PID {row['pid']}: {query_preview}")
|
|
224
|
+
typer.echo(f" User: {row['usename']}, State: {row['state']}")
|
|
225
|
+
typer.echo(f" Lock: {row['mode']} on {row['locktype']}")
|
|
226
|
+
typer.echo("")
|
|
227
|
+
|
|
228
|
+
if dry_run:
|
|
229
|
+
typer.echo("Dry run - no queries killed.")
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
# Kill the queries
|
|
233
|
+
kill_fn = "pg_terminate_backend" if force else "pg_cancel_backend"
|
|
234
|
+
killed = 0
|
|
235
|
+
|
|
236
|
+
for row in rows:
|
|
237
|
+
pid = row["pid"]
|
|
238
|
+
try:
|
|
239
|
+
result = await conn.fetchval(f"SELECT {kill_fn}($1)", pid)
|
|
240
|
+
if result:
|
|
241
|
+
killed += 1
|
|
242
|
+
if not quiet:
|
|
243
|
+
action = "Terminated" if force else "Cancelled"
|
|
244
|
+
typer.secho(f" {action} PID {pid}", fg=typer.colors.GREEN)
|
|
245
|
+
else:
|
|
246
|
+
if not quiet:
|
|
247
|
+
typer.echo(
|
|
248
|
+
f" PID {pid}: already finished or permission denied"
|
|
249
|
+
)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
if not quiet:
|
|
252
|
+
typer.secho(f" PID {pid}: Error - {e}", fg=typer.colors.YELLOW)
|
|
253
|
+
|
|
254
|
+
if not quiet:
|
|
255
|
+
typer.echo(f"\n{killed}/{len(rows)} queries killed.")
|
|
256
|
+
return killed
|
|
257
|
+
|
|
258
|
+
finally:
|
|
259
|
+
await conn.close()
|
|
260
|
+
|
|
261
|
+
count = asyncio.run(_kill_queries())
|
|
262
|
+
if count == 0 and not dry_run:
|
|
263
|
+
# Exit with 0 even if no queries found - that's success
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def register(app: typer.Typer) -> None:
|
|
268
|
+
"""Register database operations commands with the CLI app."""
|
|
269
|
+
app.command("wait")(cmd_wait)
|
|
270
|
+
app.command("kill-queries")(cmd_kill_queries)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
5
|
+
from importlib import import_module
|
|
4
6
|
from typing import List, Optional
|
|
5
7
|
|
|
6
8
|
import typer
|
|
@@ -75,10 +77,16 @@ def cmd_revision(
|
|
|
75
77
|
database_url: Optional[str] = typer.Option(
|
|
76
78
|
None, help="Database URL; overrides env for this command."
|
|
77
79
|
),
|
|
78
|
-
autogenerate: bool = typer.Option(
|
|
79
|
-
|
|
80
|
+
autogenerate: bool = typer.Option(
|
|
81
|
+
False, help="Autogenerate migrations by comparing metadata."
|
|
82
|
+
),
|
|
83
|
+
head: Optional[str] = typer.Option(
|
|
84
|
+
"head", help="Set the head to base this revision on."
|
|
85
|
+
),
|
|
80
86
|
branch_label: Optional[str] = typer.Option(None, help="Branch label."),
|
|
81
|
-
version_path: Optional[str] = typer.Option(
|
|
87
|
+
version_path: Optional[str] = typer.Option(
|
|
88
|
+
None, help="Alternative versions/ path."
|
|
89
|
+
),
|
|
82
90
|
sql: bool = typer.Option(False, help="Don't generate Python; dump SQL to stdout."),
|
|
83
91
|
):
|
|
84
92
|
"""Create a new Alembic revision, either empty or autogenerated."""
|
|
@@ -94,7 +102,9 @@ def cmd_revision(
|
|
|
94
102
|
|
|
95
103
|
|
|
96
104
|
def cmd_upgrade(
|
|
97
|
-
revision_target: str = typer.Argument(
|
|
105
|
+
revision_target: str = typer.Argument(
|
|
106
|
+
"head", help="Target revision (default head)."
|
|
107
|
+
),
|
|
98
108
|
database_url: Optional[str] = typer.Option(
|
|
99
109
|
None, help="Database URL; overrides env for this command."
|
|
100
110
|
),
|
|
@@ -123,7 +133,11 @@ def cmd_current(
|
|
|
123
133
|
):
|
|
124
134
|
"""Display the current revision for each database."""
|
|
125
135
|
apply_database_url(database_url)
|
|
126
|
-
core_current(verbose=verbose)
|
|
136
|
+
result = core_current(verbose=verbose)
|
|
137
|
+
try:
|
|
138
|
+
typer.echo(json.dumps(result))
|
|
139
|
+
except Exception:
|
|
140
|
+
typer.echo(str(result))
|
|
127
141
|
|
|
128
142
|
|
|
129
143
|
def cmd_history(
|
|
@@ -152,7 +166,9 @@ def cmd_merge_heads(
|
|
|
152
166
|
database_url: Optional[str] = typer.Option(
|
|
153
167
|
None, help="Database URL; overrides env for this command."
|
|
154
168
|
),
|
|
155
|
-
message: Optional[str] = typer.Option(
|
|
169
|
+
message: Optional[str] = typer.Option(
|
|
170
|
+
None, "-m", "--message", help="Merge revision message."
|
|
171
|
+
),
|
|
156
172
|
):
|
|
157
173
|
"""Create a merge revision for multiple heads."""
|
|
158
174
|
apply_database_url(database_url)
|
|
@@ -164,8 +180,12 @@ def cmd_setup_and_migrate(
|
|
|
164
180
|
None,
|
|
165
181
|
help="Overrides env for this command. Async vs sync is auto-detected from the URL.",
|
|
166
182
|
),
|
|
167
|
-
overwrite_scaffold: bool = typer.Option(
|
|
168
|
-
|
|
183
|
+
overwrite_scaffold: bool = typer.Option(
|
|
184
|
+
False, help="Overwrite alembic scaffold if present."
|
|
185
|
+
),
|
|
186
|
+
create_db_if_missing: bool = typer.Option(
|
|
187
|
+
True, help="Create the database/schema if missing."
|
|
188
|
+
),
|
|
169
189
|
create_followup_revision: bool = typer.Option(
|
|
170
190
|
True, help="Create an autogen follow-up revision if revisions already exist."
|
|
171
191
|
),
|
|
@@ -188,7 +208,7 @@ def cmd_setup_and_migrate(
|
|
|
188
208
|
Async vs. sync is inferred from SQL_URL.
|
|
189
209
|
"""
|
|
190
210
|
final_pkgs = _find_pkgs(with_payments, discover_packages)
|
|
191
|
-
core_setup_and_migrate(
|
|
211
|
+
result = core_setup_and_migrate(
|
|
192
212
|
overwrite_scaffold=overwrite_scaffold,
|
|
193
213
|
create_db_if_missing=create_db_if_missing,
|
|
194
214
|
create_followup_revision=create_followup_revision,
|
|
@@ -197,15 +217,80 @@ def cmd_setup_and_migrate(
|
|
|
197
217
|
discover_packages=final_pkgs or None,
|
|
198
218
|
database_url=database_url,
|
|
199
219
|
)
|
|
220
|
+
# Echo a concise JSON result so tests and users can introspect outcome
|
|
221
|
+
try:
|
|
222
|
+
typer.echo(json.dumps(result))
|
|
223
|
+
except Exception:
|
|
224
|
+
# Fallback to plain string if not JSON-serializable for any reason
|
|
225
|
+
typer.echo(str(result))
|
|
200
226
|
|
|
201
227
|
|
|
202
228
|
def register(app: typer.Typer) -> None:
|
|
203
|
-
app
|
|
204
|
-
app.command("
|
|
205
|
-
app.command("
|
|
206
|
-
app.command("
|
|
207
|
-
|
|
208
|
-
app.command(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
229
|
+
# Register under the 'sql' group app
|
|
230
|
+
app.command("init")(cmd_init)
|
|
231
|
+
app.command("revision")(cmd_revision)
|
|
232
|
+
app.command("upgrade")(cmd_upgrade)
|
|
233
|
+
# Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
|
|
234
|
+
app.command(
|
|
235
|
+
"downgrade",
|
|
236
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
237
|
+
)(cmd_downgrade)
|
|
238
|
+
app.command("current")(cmd_current)
|
|
239
|
+
app.command("history")(cmd_history)
|
|
240
|
+
app.command("stamp")(cmd_stamp)
|
|
241
|
+
app.command("merge-heads")(cmd_merge_heads)
|
|
242
|
+
app.command("setup-and-migrate")(cmd_setup_and_migrate)
|
|
243
|
+
app.command("seed")(cmd_seed)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _import_callable(path: str):
|
|
247
|
+
mod_name, _, fn_name = path.partition(":")
|
|
248
|
+
if not mod_name or not fn_name:
|
|
249
|
+
raise typer.BadParameter("Expected format 'module.path:callable'")
|
|
250
|
+
# Back-compat: after moving tests under tests/unit, allow legacy test module
|
|
251
|
+
# dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
|
|
252
|
+
mod = None
|
|
253
|
+
unit_mod = None
|
|
254
|
+
if mod_name.startswith("tests.db."):
|
|
255
|
+
# Try legacy import first (shim module), then unit module fallback
|
|
256
|
+
try:
|
|
257
|
+
mod = import_module(mod_name)
|
|
258
|
+
except ModuleNotFoundError:
|
|
259
|
+
pass
|
|
260
|
+
unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
|
|
261
|
+
try:
|
|
262
|
+
unit_mod = import_module(unit_name)
|
|
263
|
+
except ModuleNotFoundError:
|
|
264
|
+
unit_mod = None
|
|
265
|
+
# If both exist, unify shared state where applicable
|
|
266
|
+
if mod is not None and unit_mod is not None:
|
|
267
|
+
# Example: tests use a global `called` dict; point legacy to unit
|
|
268
|
+
try:
|
|
269
|
+
if hasattr(unit_mod, "called"):
|
|
270
|
+
setattr(mod, "called", getattr(unit_mod, "called"))
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
# If legacy mod missing but unit exists, use unit
|
|
274
|
+
if mod is None and unit_mod is not None:
|
|
275
|
+
mod = unit_mod
|
|
276
|
+
else:
|
|
277
|
+
mod = import_module(mod_name)
|
|
278
|
+
fn = getattr(mod, fn_name, None)
|
|
279
|
+
if not callable(fn):
|
|
280
|
+
raise typer.BadParameter(
|
|
281
|
+
f"Callable '{fn_name}' not found in module '{mod_name}'"
|
|
282
|
+
)
|
|
283
|
+
return fn
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cmd_seed(
|
|
287
|
+
target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
|
|
288
|
+
database_url: Optional[str] = typer.Option(
|
|
289
|
+
None,
|
|
290
|
+
help="Database URL; overrides env for this command.",
|
|
291
|
+
),
|
|
292
|
+
):
|
|
293
|
+
"""Run a user-provided seed function to load fixtures/reference data."""
|
|
294
|
+
apply_database_url(database_url)
|
|
295
|
+
fn = _import_callable(target)
|
|
296
|
+
fn()
|