svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- 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/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -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 +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
svc_infra/cli/__init__.py
CHANGED
|
@@ -4,10 +4,15 @@ import typer
|
|
|
4
4
|
|
|
5
5
|
from svc_infra.cli.cmds import (
|
|
6
6
|
_HELP,
|
|
7
|
+
jobs_app,
|
|
7
8
|
register_alembic,
|
|
9
|
+
register_docs,
|
|
10
|
+
register_dx,
|
|
8
11
|
register_mongo,
|
|
9
12
|
register_mongo_scaffold,
|
|
10
13
|
register_obs,
|
|
14
|
+
register_sdk,
|
|
15
|
+
register_sql_export,
|
|
11
16
|
register_sql_scaffold,
|
|
12
17
|
)
|
|
13
18
|
from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
@@ -15,16 +20,35 @@ from svc_infra.cli.foundation.typer_bootstrap import pre_cli
|
|
|
15
20
|
app = typer.Typer(no_args_is_help=True, add_completion=False, help=_HELP)
|
|
16
21
|
pre_cli(app)
|
|
17
22
|
|
|
18
|
-
# --- sql
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
# --- sql group ---
|
|
24
|
+
sql_app = typer.Typer(no_args_is_help=True, add_completion=False, help="SQL commands")
|
|
25
|
+
register_alembic(sql_app)
|
|
26
|
+
register_sql_scaffold(sql_app)
|
|
27
|
+
register_sql_export(sql_app)
|
|
28
|
+
app.add_typer(sql_app, name="sql")
|
|
21
29
|
|
|
22
|
-
# ---
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
# --- mongo group ---
|
|
31
|
+
mongo_app = typer.Typer(no_args_is_help=True, add_completion=False, help="MongoDB commands")
|
|
32
|
+
register_mongo(mongo_app)
|
|
33
|
+
register_mongo_scaffold(mongo_app)
|
|
34
|
+
app.add_typer(mongo_app, name="mongo")
|
|
25
35
|
|
|
26
|
-
# --
|
|
27
|
-
|
|
36
|
+
# -- obs group ---
|
|
37
|
+
obs_app = typer.Typer(no_args_is_help=True, add_completion=False, help="Observability commands")
|
|
38
|
+
register_obs(obs_app)
|
|
39
|
+
app.add_typer(obs_app, name="obs")
|
|
40
|
+
|
|
41
|
+
# -- dx commands ---
|
|
42
|
+
register_dx(app)
|
|
43
|
+
|
|
44
|
+
# -- jobs commands ---
|
|
45
|
+
app.add_typer(jobs_app, name="jobs")
|
|
46
|
+
|
|
47
|
+
# -- sdk commands ---
|
|
48
|
+
register_sdk(app)
|
|
49
|
+
|
|
50
|
+
# -- docs commands ---
|
|
51
|
+
register_docs(app)
|
|
28
52
|
|
|
29
53
|
|
|
30
54
|
def main():
|
svc_infra/cli/cmds/__init__.py
CHANGED
|
@@ -3,16 +3,26 @@ from svc_infra.cli.cmds.db.nosql.mongo.mongo_scaffold_cmds import (
|
|
|
3
3
|
register as register_mongo_scaffold,
|
|
4
4
|
)
|
|
5
5
|
from svc_infra.cli.cmds.db.sql.alembic_cmds import register as register_alembic
|
|
6
|
+
from svc_infra.cli.cmds.db.sql.sql_export_cmds import register as register_sql_export
|
|
6
7
|
from svc_infra.cli.cmds.db.sql.sql_scaffold_cmds import register as register_sql_scaffold
|
|
8
|
+
from svc_infra.cli.cmds.docs.docs_cmds import register as register_docs
|
|
9
|
+
from svc_infra.cli.cmds.dx import register_dx
|
|
10
|
+
from svc_infra.cli.cmds.jobs.jobs_cmds import app as jobs_app
|
|
7
11
|
from svc_infra.cli.cmds.obs.obs_cmds import register as register_obs
|
|
12
|
+
from svc_infra.cli.cmds.sdk.sdk_cmds import register as register_sdk
|
|
8
13
|
|
|
9
14
|
from .help import _HELP
|
|
10
15
|
|
|
11
16
|
__all__ = [
|
|
12
17
|
"register_alembic",
|
|
13
18
|
"register_sql_scaffold",
|
|
19
|
+
"register_sql_export",
|
|
14
20
|
"register_mongo",
|
|
15
21
|
"register_mongo_scaffold",
|
|
16
22
|
"register_obs",
|
|
23
|
+
"jobs_app",
|
|
24
|
+
"register_sdk",
|
|
25
|
+
"register_dx",
|
|
26
|
+
"register_docs",
|
|
17
27
|
"_HELP",
|
|
18
28
|
]
|
|
@@ -188,6 +188,7 @@ def cmd_ping(
|
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def register(app: typer.Typer) -> None:
|
|
191
|
-
app
|
|
192
|
-
app.command("
|
|
193
|
-
app.command("
|
|
191
|
+
# Attach to 'mongo' group app
|
|
192
|
+
app.command("prepare")(cmd_prepare)
|
|
193
|
+
app.command("setup-and-prepare")(cmd_setup_and_prepare)
|
|
194
|
+
app.command("ping")(cmd_ping)
|
|
@@ -127,7 +127,7 @@ def register(app: typer.Typer) -> None:
|
|
|
127
127
|
• mongo-scaffold-schemas
|
|
128
128
|
• mongo-scaffold-resources
|
|
129
129
|
"""
|
|
130
|
-
app.command("
|
|
131
|
-
app.command("
|
|
132
|
-
app.command("
|
|
133
|
-
app.command("
|
|
130
|
+
app.command("scaffold")(cmd_scaffold)
|
|
131
|
+
app.command("scaffold-documents")(cmd_scaffold_documents)
|
|
132
|
+
app.command("scaffold-schemas")(cmd_scaffold_schemas)
|
|
133
|
+
app.command("scaffold-resources")(cmd_scaffold_resources)
|
|
@@ -1,10 +1,13 @@
|
|
|
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
|
|
7
9
|
|
|
10
|
+
from svc_infra.apf_payments.alembic import discover_packages as payments_pkgs
|
|
8
11
|
from svc_infra.db.sql.core import current as core_current
|
|
9
12
|
from svc_infra.db.sql.core import downgrade as core_downgrade
|
|
10
13
|
from svc_infra.db.sql.core import history as core_history
|
|
@@ -22,6 +25,23 @@ def apply_database_url(database_url: Optional[str]) -> None:
|
|
|
22
25
|
os.environ["SQL_URL"] = database_url
|
|
23
26
|
|
|
24
27
|
|
|
28
|
+
def _find_pkgs(with_payments, discover_packages) -> List[str]:
|
|
29
|
+
from os import getenv
|
|
30
|
+
|
|
31
|
+
payments_enabled = (
|
|
32
|
+
with_payments
|
|
33
|
+
if with_payments is not None
|
|
34
|
+
else str(getenv("APF_ENABLE_PAYMENTS", "")).lower() in {"1", "true", "yes"}
|
|
35
|
+
)
|
|
36
|
+
final_pkgs = list(discover_packages or [])
|
|
37
|
+
if payments_enabled:
|
|
38
|
+
for p in payments_pkgs():
|
|
39
|
+
if p not in final_pkgs:
|
|
40
|
+
final_pkgs.append(p)
|
|
41
|
+
|
|
42
|
+
return final_pkgs
|
|
43
|
+
|
|
44
|
+
|
|
25
45
|
def cmd_init(
|
|
26
46
|
database_url: Optional[str] = typer.Option(
|
|
27
47
|
None,
|
|
@@ -34,14 +54,20 @@ def cmd_init(
|
|
|
34
54
|
"If omitted, automatic discovery is used.",
|
|
35
55
|
),
|
|
36
56
|
overwrite: bool = typer.Option(False, help="Overwrite existing files if present."),
|
|
57
|
+
with_payments: bool = typer.Option(
|
|
58
|
+
None,
|
|
59
|
+
help="Include svc-infra payments models when rendering env.py. "
|
|
60
|
+
"Defaults from env APF_ENABLE_PAYMENTS.",
|
|
61
|
+
),
|
|
37
62
|
):
|
|
38
63
|
"""
|
|
39
64
|
Initialize Alembic scaffold. The env.py variant (async vs. sync) is
|
|
40
65
|
auto-detected from SQL_URL (if available at init time).
|
|
41
66
|
"""
|
|
67
|
+
final_pkgs = _find_pkgs(with_payments, discover_packages)
|
|
42
68
|
apply_database_url(database_url)
|
|
43
69
|
core_init_alembic(
|
|
44
|
-
discover_packages=
|
|
70
|
+
discover_packages=final_pkgs or None,
|
|
45
71
|
overwrite=overwrite,
|
|
46
72
|
)
|
|
47
73
|
|
|
@@ -99,7 +125,11 @@ def cmd_current(
|
|
|
99
125
|
):
|
|
100
126
|
"""Display the current revision for each database."""
|
|
101
127
|
apply_database_url(database_url)
|
|
102
|
-
core_current(verbose=verbose)
|
|
128
|
+
result = core_current(verbose=verbose)
|
|
129
|
+
try:
|
|
130
|
+
typer.echo(json.dumps(result))
|
|
131
|
+
except Exception:
|
|
132
|
+
typer.echo(str(result))
|
|
103
133
|
|
|
104
134
|
|
|
105
135
|
def cmd_history(
|
|
@@ -147,28 +177,104 @@ def cmd_setup_and_migrate(
|
|
|
147
177
|
),
|
|
148
178
|
initial_message: str = typer.Option("initial schema"),
|
|
149
179
|
followup_message: str = typer.Option("autogen"),
|
|
180
|
+
# NEW:
|
|
181
|
+
discover_packages: Optional[List[str]] = typer.Option(
|
|
182
|
+
None,
|
|
183
|
+
help="Packages Alembic should import to discover models "
|
|
184
|
+
"(e.g. app.models,svc_infra.apf_payments.models).",
|
|
185
|
+
),
|
|
186
|
+
with_payments: bool = typer.Option(
|
|
187
|
+
None, # None = read env
|
|
188
|
+
help="Include svc-infra payments models in migrations. "
|
|
189
|
+
"If omitted, falls back to env APF_ENABLE_PAYMENTS=true/1.",
|
|
190
|
+
),
|
|
150
191
|
):
|
|
151
192
|
"""
|
|
152
|
-
End-to-end: ensure DB exists, scaffold Alembic, create/upgrade
|
|
193
|
+
End-to-end: ensure DB exists, scaffold Alembic, create/upgrade, all in one command.
|
|
153
194
|
Async vs. sync is inferred from SQL_URL.
|
|
154
195
|
"""
|
|
155
|
-
|
|
156
|
-
core_setup_and_migrate(
|
|
196
|
+
final_pkgs = _find_pkgs(with_payments, discover_packages)
|
|
197
|
+
result = core_setup_and_migrate(
|
|
157
198
|
overwrite_scaffold=overwrite_scaffold,
|
|
158
199
|
create_db_if_missing=create_db_if_missing,
|
|
159
200
|
create_followup_revision=create_followup_revision,
|
|
160
201
|
initial_message=initial_message,
|
|
161
202
|
followup_message=followup_message,
|
|
203
|
+
discover_packages=final_pkgs or None,
|
|
204
|
+
database_url=database_url,
|
|
162
205
|
)
|
|
206
|
+
# Echo a concise JSON result so tests and users can introspect outcome
|
|
207
|
+
try:
|
|
208
|
+
typer.echo(json.dumps(result))
|
|
209
|
+
except Exception:
|
|
210
|
+
# Fallback to plain string if not JSON-serializable for any reason
|
|
211
|
+
typer.echo(str(result))
|
|
163
212
|
|
|
164
213
|
|
|
165
214
|
def register(app: typer.Typer) -> None:
|
|
166
|
-
app
|
|
167
|
-
app.command("
|
|
168
|
-
app.command("
|
|
169
|
-
app.command("
|
|
170
|
-
|
|
171
|
-
app.command(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
215
|
+
# Register under the 'sql' group app
|
|
216
|
+
app.command("init")(cmd_init)
|
|
217
|
+
app.command("revision")(cmd_revision)
|
|
218
|
+
app.command("upgrade")(cmd_upgrade)
|
|
219
|
+
# Allow unknown options so users can pass "-1" like Alembic without Click treating it as an option
|
|
220
|
+
app.command(
|
|
221
|
+
"downgrade",
|
|
222
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
223
|
+
)(cmd_downgrade)
|
|
224
|
+
app.command("current")(cmd_current)
|
|
225
|
+
app.command("history")(cmd_history)
|
|
226
|
+
app.command("stamp")(cmd_stamp)
|
|
227
|
+
app.command("merge-heads")(cmd_merge_heads)
|
|
228
|
+
app.command("setup-and-migrate")(cmd_setup_and_migrate)
|
|
229
|
+
app.command("seed")(cmd_seed)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _import_callable(path: str):
|
|
233
|
+
mod_name, _, fn_name = path.partition(":")
|
|
234
|
+
if not mod_name or not fn_name:
|
|
235
|
+
raise typer.BadParameter("Expected format 'module.path:callable'")
|
|
236
|
+
# Back-compat: after moving tests under tests/unit, allow legacy test module
|
|
237
|
+
# dotted paths like 'tests.db.sql.test_sql_seed_cli:my_seed'.
|
|
238
|
+
mod = None
|
|
239
|
+
unit_mod = None
|
|
240
|
+
if mod_name.startswith("tests.db."):
|
|
241
|
+
# Try legacy import first (shim module), then unit module fallback
|
|
242
|
+
try:
|
|
243
|
+
mod = import_module(mod_name)
|
|
244
|
+
except ModuleNotFoundError:
|
|
245
|
+
pass
|
|
246
|
+
unit_name = mod_name.replace("tests.db.", "tests.unit.db.", 1)
|
|
247
|
+
try:
|
|
248
|
+
unit_mod = import_module(unit_name)
|
|
249
|
+
except ModuleNotFoundError:
|
|
250
|
+
unit_mod = None
|
|
251
|
+
# If both exist, unify shared state where applicable
|
|
252
|
+
if mod is not None and unit_mod is not None:
|
|
253
|
+
# Example: tests use a global `called` dict; point legacy to unit
|
|
254
|
+
try:
|
|
255
|
+
if hasattr(unit_mod, "called"):
|
|
256
|
+
setattr(mod, "called", getattr(unit_mod, "called"))
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
# If legacy mod missing but unit exists, use unit
|
|
260
|
+
if mod is None and unit_mod is not None:
|
|
261
|
+
mod = unit_mod
|
|
262
|
+
else:
|
|
263
|
+
mod = import_module(mod_name)
|
|
264
|
+
fn = getattr(mod, fn_name, None)
|
|
265
|
+
if not callable(fn):
|
|
266
|
+
raise typer.BadParameter(f"Callable '{fn_name}' not found in module '{mod_name}'")
|
|
267
|
+
return fn
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_seed(
|
|
271
|
+
target: str = typer.Argument(..., help="Seed callable path 'module:func'"),
|
|
272
|
+
database_url: Optional[str] = typer.Option(
|
|
273
|
+
None,
|
|
274
|
+
help="Database URL; overrides env for this command.",
|
|
275
|
+
),
|
|
276
|
+
):
|
|
277
|
+
"""Run a user-provided seed function to load fixtures/reference data."""
|
|
278
|
+
apply_database_url(database_url)
|
|
279
|
+
fn = _import_callable(target)
|
|
280
|
+
fn()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
|
|
13
|
+
from svc_infra.db.sql.utils import build_engine
|
|
14
|
+
|
|
15
|
+
try: # SQLAlchemy async extras are optional
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
17
|
+
except Exception: # pragma: no cover - fallback when async extras unavailable
|
|
18
|
+
AsyncEngine = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def export_tenant(
|
|
22
|
+
table: str = typer.Argument(..., help="Qualified table name to export (e.g., public.items)"),
|
|
23
|
+
tenant_id: str = typer.Option(..., "--tenant-id", help="Tenant id value to filter by."),
|
|
24
|
+
tenant_field: str = typer.Option("tenant_id", help="Column name for tenant id filter."),
|
|
25
|
+
output: Optional[Path] = typer.Option(
|
|
26
|
+
None, "--output", help="Output file; defaults to stdout."
|
|
27
|
+
),
|
|
28
|
+
limit: Optional[int] = typer.Option(None, help="Max rows to export."),
|
|
29
|
+
database_url: Optional[str] = typer.Option(
|
|
30
|
+
None, "--database-url", help="Overrides env SQL_URL for this command."
|
|
31
|
+
),
|
|
32
|
+
):
|
|
33
|
+
"""Export rows for a tenant from a given SQL table as JSON array."""
|
|
34
|
+
if database_url:
|
|
35
|
+
os.environ["SQL_URL"] = database_url
|
|
36
|
+
|
|
37
|
+
url = os.getenv("SQL_URL")
|
|
38
|
+
if not url:
|
|
39
|
+
typer.echo("SQL_URL is required (or pass --database-url)", err=True)
|
|
40
|
+
raise typer.Exit(code=2)
|
|
41
|
+
|
|
42
|
+
engine = build_engine(url)
|
|
43
|
+
rows: list[dict[str, Any]]
|
|
44
|
+
query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
|
|
45
|
+
if limit and limit > 0:
|
|
46
|
+
query += " LIMIT :limit"
|
|
47
|
+
|
|
48
|
+
params: dict[str, Any] = {"tenant_id": tenant_id}
|
|
49
|
+
if limit and limit > 0:
|
|
50
|
+
params["limit"] = int(limit)
|
|
51
|
+
|
|
52
|
+
stmt = text(query)
|
|
53
|
+
|
|
54
|
+
is_async_engine = AsyncEngine is not None and isinstance(engine, AsyncEngine)
|
|
55
|
+
|
|
56
|
+
if is_async_engine:
|
|
57
|
+
assert AsyncEngine is not None # for type checkers
|
|
58
|
+
|
|
59
|
+
async def _fetch() -> list[dict[str, Any]]:
|
|
60
|
+
async with engine.connect() as conn: # type: ignore[call-arg]
|
|
61
|
+
result = await conn.execute(stmt, params)
|
|
62
|
+
return [dict(row) for row in result.mappings()]
|
|
63
|
+
|
|
64
|
+
rows = asyncio.run(_fetch())
|
|
65
|
+
else:
|
|
66
|
+
with engine.connect() as conn: # type: ignore[attr-defined]
|
|
67
|
+
result = conn.execute(stmt, params)
|
|
68
|
+
rows = [dict(row) for row in result.mappings()]
|
|
69
|
+
|
|
70
|
+
data = json.dumps(rows, indent=2)
|
|
71
|
+
if output:
|
|
72
|
+
output.write_text(data)
|
|
73
|
+
typer.echo(str(output))
|
|
74
|
+
else:
|
|
75
|
+
sys.stdout.write(data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def register(app_root: typer.Typer) -> None:
|
|
79
|
+
# Attach directly to the provided 'sql' group app
|
|
80
|
+
app_root.command("export-tenant")(export_tenant)
|
|
@@ -24,7 +24,8 @@ def cmd_scaffold(
|
|
|
24
24
|
"Item", help="Class name for entity/auth (e.g., User, Member, Product)."
|
|
25
25
|
),
|
|
26
26
|
table_name: Optional[str] = typer.Option(
|
|
27
|
-
None,
|
|
27
|
+
None,
|
|
28
|
+
help="Optional table name. For kind=auth, can also be set via AUTH_TABLE_NAME; defaults to plural snake of entity.",
|
|
28
29
|
),
|
|
29
30
|
models_dir: Path = typer.Option(..., help="Directory for models."),
|
|
30
31
|
schemas_dir: Path = typer.Option(..., help="Directory for schemas."),
|
|
@@ -133,6 +134,6 @@ def cmd_scaffold_schemas(
|
|
|
133
134
|
|
|
134
135
|
|
|
135
136
|
def register(app: typer.Typer) -> None:
|
|
136
|
-
app.command("
|
|
137
|
-
app.command("
|
|
138
|
-
app.command("
|
|
137
|
+
app.command("scaffold")(cmd_scaffold)
|
|
138
|
+
app.command("scaffold-models")(cmd_scaffold_models)
|
|
139
|
+
app.command("scaffold-schemas")(cmd_scaffold_schemas)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib.resources import as_file
|
|
5
|
+
from importlib.resources import files as pkg_files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import typer
|
|
11
|
+
from typer.core import TyperGroup
|
|
12
|
+
|
|
13
|
+
from svc_infra.app.root import resolve_project_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _norm(name: str) -> str:
|
|
17
|
+
return name.strip().lower().replace(" ", "-").replace("_", "-")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _discover_fs_topics(docs_dir: Path) -> Dict[str, Path]:
|
|
21
|
+
topics: Dict[str, Path] = {}
|
|
22
|
+
if docs_dir.exists() and docs_dir.is_dir():
|
|
23
|
+
for p in sorted(docs_dir.glob("*.md")):
|
|
24
|
+
if p.is_file():
|
|
25
|
+
topics[_norm(p.stem)] = p
|
|
26
|
+
return topics
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _discover_pkg_topics() -> Dict[str, Path]:
|
|
30
|
+
"""
|
|
31
|
+
Discover docs shipped inside the installed package at svc_infra/docs/*,
|
|
32
|
+
using importlib.resources so this works for wheels, sdists, and zipped wheels.
|
|
33
|
+
"""
|
|
34
|
+
topics: Dict[str, Path] = {}
|
|
35
|
+
try:
|
|
36
|
+
docs_root = pkg_files("svc_infra").joinpath("docs")
|
|
37
|
+
# docs_root is a Traversable; it may be inside a zip. Iterate safely.
|
|
38
|
+
for entry in docs_root.iterdir():
|
|
39
|
+
if entry.name.endswith(".md"):
|
|
40
|
+
# materialize to a real tempfile path if needed
|
|
41
|
+
with as_file(entry) as concrete:
|
|
42
|
+
p = Path(concrete)
|
|
43
|
+
if p.exists() and p.is_file():
|
|
44
|
+
topics[_norm(p.stem)] = p
|
|
45
|
+
except Exception:
|
|
46
|
+
# If the package has no docs directory, just return empty.
|
|
47
|
+
pass
|
|
48
|
+
return topics
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_docs_dir(ctx: click.Context) -> Path | None:
|
|
52
|
+
"""
|
|
53
|
+
Optional override precedence:
|
|
54
|
+
1) SVC_INFRA_DOCS_DIR env var
|
|
55
|
+
2) *Only when working inside the svc-infra repo itself*: repo-root /docs
|
|
56
|
+
"""
|
|
57
|
+
# 1) Env var
|
|
58
|
+
env_dir = os.getenv("SVC_INFRA_DOCS_DIR")
|
|
59
|
+
if env_dir:
|
|
60
|
+
p = Path(env_dir).expanduser()
|
|
61
|
+
if p.exists():
|
|
62
|
+
return p
|
|
63
|
+
|
|
64
|
+
# 2) In-repo convenience (so `svc-infra docs` works inside this repo)
|
|
65
|
+
try:
|
|
66
|
+
root = resolve_project_root()
|
|
67
|
+
proj_docs = root / "docs"
|
|
68
|
+
if proj_docs.exists():
|
|
69
|
+
return proj_docs
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DocsGroup(TyperGroup):
|
|
77
|
+
def list_commands(self, ctx: click.Context) -> List[str]:
|
|
78
|
+
names: List[str] = list(super().list_commands(ctx) or [])
|
|
79
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
80
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
81
|
+
pkg = _discover_pkg_topics()
|
|
82
|
+
names.extend(fs.keys())
|
|
83
|
+
names.extend([k for k in pkg.keys() if k not in fs])
|
|
84
|
+
return sorted(set(names))
|
|
85
|
+
|
|
86
|
+
def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
|
|
87
|
+
cmd = super().get_command(ctx, name)
|
|
88
|
+
if cmd is not None:
|
|
89
|
+
return cmd
|
|
90
|
+
|
|
91
|
+
key = _norm(name)
|
|
92
|
+
|
|
93
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
94
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
95
|
+
if key in fs:
|
|
96
|
+
file_path = fs[key]
|
|
97
|
+
|
|
98
|
+
@click.command(name=name)
|
|
99
|
+
def _show_fs() -> None:
|
|
100
|
+
click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
101
|
+
|
|
102
|
+
return _show_fs
|
|
103
|
+
|
|
104
|
+
pkg = _discover_pkg_topics()
|
|
105
|
+
if key in pkg:
|
|
106
|
+
file_path = pkg[key]
|
|
107
|
+
|
|
108
|
+
@click.command(name=name)
|
|
109
|
+
def _show_pkg() -> None:
|
|
110
|
+
click.echo(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
111
|
+
|
|
112
|
+
return _show_pkg
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register(app: typer.Typer) -> None:
|
|
118
|
+
docs_app = typer.Typer(no_args_is_help=True, add_completion=False, cls=DocsGroup)
|
|
119
|
+
|
|
120
|
+
@docs_app.callback(invoke_without_command=True)
|
|
121
|
+
def _docs_options() -> None:
|
|
122
|
+
# No group-level options; dynamic commands and 'show' handle topics.
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
@docs_app.command("show", help="Show docs for a topic (alternative to dynamic subcommand)")
|
|
126
|
+
def show(topic: str) -> None:
|
|
127
|
+
key = _norm(topic)
|
|
128
|
+
ctx = click.get_current_context()
|
|
129
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
130
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
131
|
+
if key in fs:
|
|
132
|
+
typer.echo(fs[key].read_text(encoding="utf-8", errors="replace"))
|
|
133
|
+
return
|
|
134
|
+
pkg = _discover_pkg_topics()
|
|
135
|
+
if key in pkg:
|
|
136
|
+
typer.echo(pkg[key].read_text(encoding="utf-8", errors="replace"))
|
|
137
|
+
return
|
|
138
|
+
raise typer.BadParameter(f"Unknown topic: {topic}")
|
|
139
|
+
|
|
140
|
+
app.add_typer(docs_app, name="docs")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from svc_infra.dx.changelog import Commit, generate_release_section
|
|
9
|
+
from svc_infra.dx.checks import check_migrations_up_to_date, check_openapi_problem_schema
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("openapi")
|
|
15
|
+
def cmd_openapi(path: str = typer.Argument(..., help="Path to OpenAPI JSON")):
|
|
16
|
+
try:
|
|
17
|
+
check_openapi_problem_schema(path=path)
|
|
18
|
+
except Exception as e: # noqa: BLE001
|
|
19
|
+
typer.secho(f"OpenAPI check failed: {e}", fg=typer.colors.RED, err=True)
|
|
20
|
+
raise typer.Exit(2)
|
|
21
|
+
typer.secho("OpenAPI checks passed", fg=typer.colors.GREEN)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("migrations")
|
|
25
|
+
def cmd_migrations(project_root: str = typer.Option(".", help="Project root")):
|
|
26
|
+
try:
|
|
27
|
+
check_migrations_up_to_date(project_root=project_root)
|
|
28
|
+
except Exception as e: # noqa: BLE001
|
|
29
|
+
typer.secho(f"Migrations check failed: {e}", fg=typer.colors.RED, err=True)
|
|
30
|
+
raise typer.Exit(2)
|
|
31
|
+
typer.secho("Migrations checks passed", fg=typer.colors.GREEN)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("changelog")
|
|
35
|
+
def cmd_changelog(
|
|
36
|
+
version: str = typer.Argument(..., help="Version (e.g., 0.1.604)"),
|
|
37
|
+
commits_file: str = typer.Option(None, help="Path to JSON lines of commits (sha,subject)"),
|
|
38
|
+
):
|
|
39
|
+
"""Generate a changelog section from commit messages.
|
|
40
|
+
|
|
41
|
+
Expects Conventional Commits style for best grouping; falls back to Other.
|
|
42
|
+
If commits_file is omitted, prints an example format.
|
|
43
|
+
"""
|
|
44
|
+
import json
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
if not commits_file:
|
|
48
|
+
typer.echo(
|
|
49
|
+
'# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
|
|
50
|
+
err=True,
|
|
51
|
+
)
|
|
52
|
+
raise typer.Exit(2)
|
|
53
|
+
rows = [
|
|
54
|
+
json.loads(line) for line in Path(commits_file).read_text().splitlines() if line.strip()
|
|
55
|
+
]
|
|
56
|
+
commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
|
|
57
|
+
out = generate_release_section(version=version, commits=commits)
|
|
58
|
+
sys.stdout.write(out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("ci")
|
|
62
|
+
def cmd_ci(
|
|
63
|
+
run: bool = typer.Option(False, help="Execute the steps; default just prints a plan"),
|
|
64
|
+
openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
|
|
65
|
+
project_root: str = typer.Option(".", help="Project root for migrations check"),
|
|
66
|
+
):
|
|
67
|
+
"""Print (or run) the CI steps locally to mirror the workflow."""
|
|
68
|
+
steps: list[list[str]] = []
|
|
69
|
+
# Lint, typecheck, tests
|
|
70
|
+
steps.append(["flake8", "--select=E,F"]) # mirrors CI
|
|
71
|
+
steps.append(["mypy", "src"]) # mirrors CI
|
|
72
|
+
if openapi:
|
|
73
|
+
steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
|
|
74
|
+
steps.append(
|
|
75
|
+
[sys.executable, "-m", "svc_infra.cli", "dx", "migrations", "--project-root", project_root]
|
|
76
|
+
)
|
|
77
|
+
steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
|
|
78
|
+
|
|
79
|
+
if not run:
|
|
80
|
+
typer.echo("CI dry-run plan:")
|
|
81
|
+
for cmd in steps:
|
|
82
|
+
typer.echo(" $ " + " ".join(cmd))
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
import subprocess
|
|
86
|
+
|
|
87
|
+
for cmd in steps:
|
|
88
|
+
typer.echo("Running: " + " ".join(cmd))
|
|
89
|
+
res = subprocess.run(cmd)
|
|
90
|
+
if res.returncode != 0:
|
|
91
|
+
raise typer.Exit(res.returncode)
|
|
92
|
+
typer.echo("All CI steps passed")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(): # pragma: no cover - CLI entrypoint
|
|
96
|
+
app()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["main", "app"]
|
svc_infra/cli/cmds/help.py
CHANGED
|
@@ -21,4 +21,8 @@ How to run (pick what fits your workflow):
|
|
|
21
21
|
Notes:
|
|
22
22
|
* Make sure you’re in the right virtual environment (or use `pipx`).
|
|
23
23
|
* You can point `--project-root` at your Alembic root; if omitted we auto-detect.
|
|
24
|
+
|
|
25
|
+
Learn more:
|
|
26
|
+
* Explore available topics: `svc-infra docs --help`
|
|
27
|
+
* Show a topic directly: `svc-infra docs <topic>` or `svc-infra docs show <topic>`
|
|
24
28
|
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|