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
|
@@ -0,0 +1,116 @@
|
|
|
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 (
|
|
10
|
+
check_migrations_up_to_date,
|
|
11
|
+
check_openapi_problem_schema,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("openapi")
|
|
18
|
+
def cmd_openapi(path: str = typer.Argument(..., help="Path to OpenAPI JSON")):
|
|
19
|
+
try:
|
|
20
|
+
check_openapi_problem_schema(path=path)
|
|
21
|
+
except Exception as e: # noqa: BLE001
|
|
22
|
+
typer.secho(f"OpenAPI check failed: {e}", fg=typer.colors.RED, err=True)
|
|
23
|
+
raise typer.Exit(2)
|
|
24
|
+
typer.secho("OpenAPI checks passed", fg=typer.colors.GREEN)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("migrations")
|
|
28
|
+
def cmd_migrations(project_root: str = typer.Option(".", help="Project root")):
|
|
29
|
+
try:
|
|
30
|
+
check_migrations_up_to_date(project_root=project_root)
|
|
31
|
+
except Exception as e: # noqa: BLE001
|
|
32
|
+
typer.secho(f"Migrations check failed: {e}", fg=typer.colors.RED, err=True)
|
|
33
|
+
raise typer.Exit(2)
|
|
34
|
+
typer.secho("Migrations checks passed", fg=typer.colors.GREEN)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("changelog")
|
|
38
|
+
def cmd_changelog(
|
|
39
|
+
version: str = typer.Argument(..., help="Version (e.g., 0.1.604)"),
|
|
40
|
+
commits_file: str = typer.Option(
|
|
41
|
+
None, help="Path to JSON lines of commits (sha,subject)"
|
|
42
|
+
),
|
|
43
|
+
):
|
|
44
|
+
"""Generate a changelog section from commit messages.
|
|
45
|
+
|
|
46
|
+
Expects Conventional Commits style for best grouping; falls back to Other.
|
|
47
|
+
If commits_file is omitted, prints an example format.
|
|
48
|
+
"""
|
|
49
|
+
import json
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
if not commits_file:
|
|
53
|
+
typer.echo(
|
|
54
|
+
'# Provide --commits-file with JSONL: {"sha": "<sha>", "subject": "feat: ..."}',
|
|
55
|
+
err=True,
|
|
56
|
+
)
|
|
57
|
+
raise typer.Exit(2)
|
|
58
|
+
rows = [
|
|
59
|
+
json.loads(line)
|
|
60
|
+
for line in Path(commits_file).read_text().splitlines()
|
|
61
|
+
if line.strip()
|
|
62
|
+
]
|
|
63
|
+
commits = [Commit(sha=r["sha"], subject=r["subject"]) for r in rows]
|
|
64
|
+
out = generate_release_section(version=version, commits=commits)
|
|
65
|
+
sys.stdout.write(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("ci")
|
|
69
|
+
def cmd_ci(
|
|
70
|
+
run: bool = typer.Option(
|
|
71
|
+
False, help="Execute the steps; default just prints a plan"
|
|
72
|
+
),
|
|
73
|
+
openapi: str | None = typer.Option(None, help="Path to OpenAPI JSON to lint"),
|
|
74
|
+
project_root: str = typer.Option(".", help="Project root for migrations check"),
|
|
75
|
+
):
|
|
76
|
+
"""Print (or run) the CI steps locally to mirror the workflow."""
|
|
77
|
+
steps: list[list[str]] = []
|
|
78
|
+
# Lint, typecheck, tests
|
|
79
|
+
steps.append(["flake8", "--select=E,F"]) # mirrors CI
|
|
80
|
+
steps.append(["mypy", "src"]) # mirrors CI
|
|
81
|
+
if openapi:
|
|
82
|
+
steps.append([sys.executable, "-m", "svc_infra.cli", "dx", "openapi", openapi])
|
|
83
|
+
steps.append(
|
|
84
|
+
[
|
|
85
|
+
sys.executable,
|
|
86
|
+
"-m",
|
|
87
|
+
"svc_infra.cli",
|
|
88
|
+
"dx",
|
|
89
|
+
"migrations",
|
|
90
|
+
"--project-root",
|
|
91
|
+
project_root,
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
steps.append(["pytest", "-q", "-W", "error"]) # mirrors CI
|
|
95
|
+
|
|
96
|
+
if not run:
|
|
97
|
+
typer.echo("CI dry-run plan:")
|
|
98
|
+
for cmd in steps:
|
|
99
|
+
typer.echo(" $ " + " ".join(cmd))
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
import subprocess
|
|
103
|
+
|
|
104
|
+
for cmd in steps:
|
|
105
|
+
typer.echo("Running: " + " ".join(cmd))
|
|
106
|
+
res = subprocess.run(cmd)
|
|
107
|
+
if res.returncode != 0:
|
|
108
|
+
raise typer.Exit(res.returncode)
|
|
109
|
+
typer.echo("All CI steps passed")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(): # pragma: no cover - CLI entrypoint
|
|
113
|
+
app()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["main", "app"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Health check CLI commands.
|
|
2
|
+
|
|
3
|
+
Provides CLI commands for health checking:
|
|
4
|
+
- check: Check health of a URL endpoint
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_check(
|
|
16
|
+
url: str = typer.Argument(
|
|
17
|
+
...,
|
|
18
|
+
help="URL of the health endpoint to check.",
|
|
19
|
+
),
|
|
20
|
+
timeout: float = typer.Option(
|
|
21
|
+
10.0,
|
|
22
|
+
"--timeout",
|
|
23
|
+
"-t",
|
|
24
|
+
help="Request timeout in seconds.",
|
|
25
|
+
),
|
|
26
|
+
json_output: bool = typer.Option(
|
|
27
|
+
False,
|
|
28
|
+
"--json",
|
|
29
|
+
"-j",
|
|
30
|
+
help="Output as JSON.",
|
|
31
|
+
),
|
|
32
|
+
verbose: bool = typer.Option(
|
|
33
|
+
False,
|
|
34
|
+
"--verbose",
|
|
35
|
+
"-v",
|
|
36
|
+
help="Show detailed response.",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Check health of a URL endpoint.
|
|
41
|
+
|
|
42
|
+
Fetches the URL and reports the health status based on HTTP response.
|
|
43
|
+
Expects the endpoint to return 200 for healthy status.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
svc-infra health check http://localhost:8000/health
|
|
47
|
+
svc-infra health check http://api:8080/ready --timeout 5
|
|
48
|
+
svc-infra health check http://localhost:8000/health --json
|
|
49
|
+
|
|
50
|
+
Exit codes:
|
|
51
|
+
0: Healthy (HTTP 2xx)
|
|
52
|
+
1: Unhealthy or unreachable
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
async def _check() -> dict:
|
|
56
|
+
"""Perform the health check and return result."""
|
|
57
|
+
from svc_infra.health import check_url
|
|
58
|
+
|
|
59
|
+
# Create the check function with the given URL
|
|
60
|
+
check_fn = check_url(url, timeout=timeout)
|
|
61
|
+
|
|
62
|
+
# Run the check
|
|
63
|
+
result = await check_fn()
|
|
64
|
+
|
|
65
|
+
return result.to_dict()
|
|
66
|
+
|
|
67
|
+
result = asyncio.run(_check())
|
|
68
|
+
|
|
69
|
+
if json_output:
|
|
70
|
+
typer.echo(json.dumps(result, indent=2))
|
|
71
|
+
else:
|
|
72
|
+
status = result["status"]
|
|
73
|
+
latency = result["latency_ms"]
|
|
74
|
+
|
|
75
|
+
if status == "healthy":
|
|
76
|
+
typer.secho(f"✓ {url}", fg=typer.colors.GREEN)
|
|
77
|
+
typer.echo(f" Status: {status} ({latency:.1f}ms)")
|
|
78
|
+
else:
|
|
79
|
+
typer.secho(f"✗ {url}", fg=typer.colors.RED)
|
|
80
|
+
typer.echo(f" Status: {status}")
|
|
81
|
+
if result.get("message"):
|
|
82
|
+
typer.echo(f" Message: {result['message']}")
|
|
83
|
+
|
|
84
|
+
if verbose and result.get("details"):
|
|
85
|
+
typer.echo(" Details:")
|
|
86
|
+
for key, value in result["details"].items():
|
|
87
|
+
typer.echo(f" {key}: {value}")
|
|
88
|
+
|
|
89
|
+
# Exit with error code if unhealthy
|
|
90
|
+
if result["status"] != "healthy":
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_wait(
|
|
95
|
+
url: str = typer.Argument(
|
|
96
|
+
...,
|
|
97
|
+
help="URL of the health endpoint to wait for.",
|
|
98
|
+
),
|
|
99
|
+
timeout: int = typer.Option(
|
|
100
|
+
60,
|
|
101
|
+
"--timeout",
|
|
102
|
+
"-t",
|
|
103
|
+
help="Maximum time to wait in seconds.",
|
|
104
|
+
),
|
|
105
|
+
interval: float = typer.Option(
|
|
106
|
+
2.0,
|
|
107
|
+
"--interval",
|
|
108
|
+
"-i",
|
|
109
|
+
help="Time between checks in seconds.",
|
|
110
|
+
),
|
|
111
|
+
quiet: bool = typer.Option(
|
|
112
|
+
False,
|
|
113
|
+
"--quiet",
|
|
114
|
+
"-q",
|
|
115
|
+
help="Suppress progress messages.",
|
|
116
|
+
),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Wait for a health endpoint to become healthy.
|
|
120
|
+
|
|
121
|
+
Repeatedly checks the URL until it returns a healthy response
|
|
122
|
+
or timeout is reached.
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
svc-infra health wait http://localhost:8000/health
|
|
126
|
+
svc-infra health wait http://api:8080/ready --timeout 120
|
|
127
|
+
|
|
128
|
+
Exit codes:
|
|
129
|
+
0: Endpoint became healthy
|
|
130
|
+
1: Timeout reached, endpoint not healthy
|
|
131
|
+
"""
|
|
132
|
+
import time
|
|
133
|
+
|
|
134
|
+
async def _wait() -> bool:
|
|
135
|
+
"""Wait loop."""
|
|
136
|
+
from svc_infra.health import check_url
|
|
137
|
+
|
|
138
|
+
check_fn = check_url(url, timeout=5.0)
|
|
139
|
+
deadline = time.monotonic() + timeout
|
|
140
|
+
attempt = 0
|
|
141
|
+
|
|
142
|
+
while time.monotonic() < deadline:
|
|
143
|
+
attempt += 1
|
|
144
|
+
if not quiet:
|
|
145
|
+
typer.echo(f"Attempt {attempt}: Checking {url}...")
|
|
146
|
+
|
|
147
|
+
result = await check_fn()
|
|
148
|
+
|
|
149
|
+
if result.status == "healthy":
|
|
150
|
+
if not quiet:
|
|
151
|
+
typer.secho(
|
|
152
|
+
f"✓ Healthy ({result.latency_ms:.1f}ms)",
|
|
153
|
+
fg=typer.colors.GREEN,
|
|
154
|
+
)
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
if not quiet:
|
|
158
|
+
msg = result.message or "Unhealthy"
|
|
159
|
+
typer.echo(f" → {msg}")
|
|
160
|
+
|
|
161
|
+
remaining = deadline - time.monotonic()
|
|
162
|
+
if remaining > 0:
|
|
163
|
+
await asyncio.sleep(min(interval, remaining))
|
|
164
|
+
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
success = asyncio.run(_wait())
|
|
168
|
+
if not success:
|
|
169
|
+
typer.secho(
|
|
170
|
+
f"ERROR: Endpoint not healthy after {timeout}s",
|
|
171
|
+
fg=typer.colors.RED,
|
|
172
|
+
)
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def register(app: typer.Typer) -> None:
|
|
177
|
+
"""Register health check commands with the CLI app."""
|
|
178
|
+
app.command("check")(cmd_check)
|
|
179
|
+
app.command("wait")(cmd_wait)
|
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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from svc_infra.jobs.easy import easy_jobs
|
|
9
|
+
from svc_infra.jobs.loader import schedule_from_env
|
|
10
|
+
from svc_infra.jobs.worker import process_one
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Background jobs and scheduler commands")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("run")
|
|
16
|
+
def run(
|
|
17
|
+
poll_interval: float = typer.Option(
|
|
18
|
+
0.5, help="Sleep seconds between loops when idle"
|
|
19
|
+
),
|
|
20
|
+
max_loops: Optional[int] = typer.Option(
|
|
21
|
+
None, help="Max loops before exit (for tests)"
|
|
22
|
+
),
|
|
23
|
+
):
|
|
24
|
+
"""Run scheduler ticks and process jobs in a simple loop."""
|
|
25
|
+
|
|
26
|
+
queue, scheduler = easy_jobs()
|
|
27
|
+
# load schedule from env JSON if provided
|
|
28
|
+
schedule_from_env(scheduler)
|
|
29
|
+
|
|
30
|
+
async def _loop():
|
|
31
|
+
loops = 0
|
|
32
|
+
while True:
|
|
33
|
+
await scheduler.tick()
|
|
34
|
+
processed = await process_one(queue, _noop_handler)
|
|
35
|
+
if not processed:
|
|
36
|
+
# idle
|
|
37
|
+
await asyncio.sleep(poll_interval)
|
|
38
|
+
if max_loops is not None:
|
|
39
|
+
loops += 1
|
|
40
|
+
if loops >= max_loops:
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
async def _noop_handler(job):
|
|
44
|
+
# Default handler does nothing; users should write their own runners
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
asyncio.run(_loop())
|
|
@@ -4,18 +4,22 @@ import os
|
|
|
4
4
|
import socket
|
|
5
5
|
import subprocess
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
7
8
|
from urllib.parse import urlparse
|
|
8
9
|
|
|
9
10
|
import typer
|
|
10
11
|
|
|
12
|
+
from svc_infra.obs.cloud_dash import push_dashboards_from_pkg
|
|
13
|
+
from svc_infra.utils import render_template, write
|
|
14
|
+
|
|
11
15
|
# --- NEW: load .env automatically (best-effort) ---
|
|
16
|
+
load_dotenv: Callable[..., Any] | None
|
|
12
17
|
try:
|
|
13
|
-
from dotenv import load_dotenv
|
|
18
|
+
from dotenv import load_dotenv as _real_load_dotenv
|
|
14
19
|
except Exception: # pragma: no cover
|
|
15
20
|
load_dotenv = None
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
from svc_infra.utils import render_template, write
|
|
21
|
+
else:
|
|
22
|
+
load_dotenv = _real_load_dotenv
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
def _run(cmd: list[str], *, env: dict | None = None):
|
|
@@ -25,7 +29,9 @@ def _run(cmd: list[str], *, env: dict | None = None):
|
|
|
25
29
|
def _emit_local_stack(root: Path, metrics_url: str):
|
|
26
30
|
write(
|
|
27
31
|
root / "docker-compose.yml",
|
|
28
|
-
render_template(
|
|
32
|
+
render_template(
|
|
33
|
+
"svc_infra.obs.providers.grafana.templates", "docker-compose.yml.tmpl", {}
|
|
34
|
+
),
|
|
29
35
|
)
|
|
30
36
|
p = urlparse(metrics_url)
|
|
31
37
|
prom_yml = render_template(
|
|
@@ -102,7 +108,7 @@ def up():
|
|
|
102
108
|
- Else → Local mode (Grafana + Prometheus).
|
|
103
109
|
"""
|
|
104
110
|
# NEW: load .env once, best-effort, without crashing if package missing
|
|
105
|
-
if load_dotenv:
|
|
111
|
+
if load_dotenv is not None:
|
|
106
112
|
try:
|
|
107
113
|
load_dotenv(dotenv_path=Path(".env"), override=False)
|
|
108
114
|
except Exception:
|
|
@@ -110,7 +116,9 @@ def up():
|
|
|
110
116
|
|
|
111
117
|
root = Path(".obs")
|
|
112
118
|
root.mkdir(exist_ok=True)
|
|
113
|
-
metrics_url = os.getenv(
|
|
119
|
+
metrics_url = os.getenv(
|
|
120
|
+
"SVC_INFRA_METRICS_URL", "http://host.docker.internal:8000/metrics"
|
|
121
|
+
)
|
|
114
122
|
|
|
115
123
|
cloud_url = os.getenv("GRAFANA_CLOUD_URL", "").strip()
|
|
116
124
|
cloud_token = os.getenv("GRAFANA_CLOUD_TOKEN", "").strip()
|
|
@@ -131,7 +139,14 @@ def up():
|
|
|
131
139
|
):
|
|
132
140
|
_emit_local_agent(root, metrics_url)
|
|
133
141
|
_run(
|
|
134
|
-
[
|
|
142
|
+
[
|
|
143
|
+
"docker",
|
|
144
|
+
"compose",
|
|
145
|
+
"-f",
|
|
146
|
+
str(root / "docker-compose.cloud.yml"),
|
|
147
|
+
"up",
|
|
148
|
+
"-d",
|
|
149
|
+
],
|
|
135
150
|
env=os.environ.copy(),
|
|
136
151
|
)
|
|
137
152
|
typer.echo("[cloud] local Grafana Agent started (pushing metrics to Cloud)")
|
|
@@ -146,7 +161,10 @@ def up():
|
|
|
146
161
|
env["GRAFANA_PORT"] = str(local_graf)
|
|
147
162
|
env["PROM_PORT"] = str(local_prom)
|
|
148
163
|
_emit_local_stack(root, metrics_url)
|
|
149
|
-
_run(
|
|
164
|
+
_run(
|
|
165
|
+
["docker", "compose", "-f", str(root / "docker-compose.yml"), "up", "-d"],
|
|
166
|
+
env=env,
|
|
167
|
+
)
|
|
150
168
|
typer.echo(f"Local Grafana → http://localhost:{local_graf} (admin/admin)")
|
|
151
169
|
typer.echo(f"Local Prometheus → http://localhost:{local_prom}")
|
|
152
170
|
|
|
@@ -155,11 +173,13 @@ def down():
|
|
|
155
173
|
root = Path(".obs")
|
|
156
174
|
if (root / "docker-compose.yml").exists():
|
|
157
175
|
subprocess.run(
|
|
158
|
-
["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"],
|
|
176
|
+
["docker", "compose", "-f", str(root / "docker-compose.yml"), "down"],
|
|
177
|
+
check=False,
|
|
159
178
|
)
|
|
160
179
|
if (root / "docker-compose.cloud.yml").exists():
|
|
161
180
|
subprocess.run(
|
|
162
|
-
["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "down"],
|
|
181
|
+
["docker", "compose", "-f", str(root / "docker-compose.cloud.yml"), "down"],
|
|
182
|
+
check=False,
|
|
163
183
|
)
|
|
164
184
|
typer.echo("Stopped local obs services.")
|
|
165
185
|
|
|
@@ -171,7 +191,7 @@ def scaffold(target: str = typer.Option(..., help="compose|railway|k8s|fly")):
|
|
|
171
191
|
out.mkdir(parents=True, exist_ok=True)
|
|
172
192
|
|
|
173
193
|
base = files("svc_infra.obs.templates.sidecars").joinpath(target)
|
|
174
|
-
for p in base.rglob("*"):
|
|
194
|
+
for p in base.rglob("*"): # type: ignore[attr-defined]
|
|
175
195
|
if p.is_file():
|
|
176
196
|
rel = p.relative_to(base)
|
|
177
197
|
dst = out / rel
|
|
@@ -182,6 +202,7 @@ def scaffold(target: str = typer.Option(..., help="compose|railway|k8s|fly")):
|
|
|
182
202
|
|
|
183
203
|
|
|
184
204
|
def register(app: typer.Typer) -> None:
|
|
185
|
-
app
|
|
186
|
-
app.command("
|
|
187
|
-
app.command("
|
|
205
|
+
# Attach to 'obs' group app
|
|
206
|
+
app.command("up")(up)
|
|
207
|
+
app.command("down")(down)
|
|
208
|
+
app.command("scaffold")(scaffold)
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
no_args_is_help=True, add_completion=False, help="Generate SDKs from OpenAPI."
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _echo(cmd: list[str]):
|
|
13
|
+
typer.echo("$ " + " ".join(cmd))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_bool(val: str | bool | None, default: bool = True) -> bool:
|
|
17
|
+
if isinstance(val, bool):
|
|
18
|
+
return val
|
|
19
|
+
if val is None:
|
|
20
|
+
return default
|
|
21
|
+
s = str(val).strip().lower()
|
|
22
|
+
if s in {"1", "true", "yes", "y"}:
|
|
23
|
+
return True
|
|
24
|
+
if s in {"0", "false", "no", "n"}:
|
|
25
|
+
return False
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("ts")
|
|
30
|
+
def sdk_ts(
|
|
31
|
+
openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
|
|
32
|
+
outdir: str = typer.Option("sdk-ts", help="Output directory"),
|
|
33
|
+
dry_run: str = typer.Option(
|
|
34
|
+
"true", help="Print commands instead of running (true/false)"
|
|
35
|
+
),
|
|
36
|
+
):
|
|
37
|
+
"""Generate a TypeScript SDK (openapi-typescript-codegen as default)."""
|
|
38
|
+
cmd = [
|
|
39
|
+
"npx",
|
|
40
|
+
"openapi-typescript-codegen",
|
|
41
|
+
"--input",
|
|
42
|
+
openapi,
|
|
43
|
+
"--output",
|
|
44
|
+
outdir,
|
|
45
|
+
]
|
|
46
|
+
if _parse_bool(dry_run, True):
|
|
47
|
+
_echo(cmd)
|
|
48
|
+
return
|
|
49
|
+
subprocess.check_call(cmd)
|
|
50
|
+
typer.secho(f"TS SDK generated → {outdir}", fg=typer.colors.GREEN)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("py")
|
|
54
|
+
def sdk_py(
|
|
55
|
+
openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
|
|
56
|
+
outdir: str = typer.Option("sdk-py", help="Output directory"),
|
|
57
|
+
package_name: str = typer.Option("client_sdk", help="Python package name"),
|
|
58
|
+
dry_run: str = typer.Option(
|
|
59
|
+
"true", help="Print commands instead of running (true/false)"
|
|
60
|
+
),
|
|
61
|
+
):
|
|
62
|
+
"""Generate a Python SDK via openapi-generator-cli with "python" generator."""
|
|
63
|
+
cmd = [
|
|
64
|
+
"npx",
|
|
65
|
+
"-y",
|
|
66
|
+
"@openapitools/openapi-generator-cli",
|
|
67
|
+
"generate",
|
|
68
|
+
"-i",
|
|
69
|
+
openapi,
|
|
70
|
+
"-g",
|
|
71
|
+
"python",
|
|
72
|
+
"-o",
|
|
73
|
+
outdir,
|
|
74
|
+
"--additional-properties",
|
|
75
|
+
f"packageName={package_name}",
|
|
76
|
+
]
|
|
77
|
+
if _parse_bool(dry_run, True):
|
|
78
|
+
_echo(cmd)
|
|
79
|
+
return
|
|
80
|
+
subprocess.check_call(cmd)
|
|
81
|
+
typer.secho(f"Python SDK generated → {outdir}", fg=typer.colors.GREEN)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("postman")
|
|
85
|
+
def sdk_postman(
|
|
86
|
+
openapi: str = typer.Argument(..., help="Path to OpenAPI JSON"),
|
|
87
|
+
out: str = typer.Option(
|
|
88
|
+
"postman_collection.json", help="Output Postman collection"
|
|
89
|
+
),
|
|
90
|
+
dry_run: str = typer.Option(
|
|
91
|
+
"true", help="Print commands instead of running (true/false)"
|
|
92
|
+
),
|
|
93
|
+
):
|
|
94
|
+
"""Convert OpenAPI to a Postman collection via openapi-to-postmanv2."""
|
|
95
|
+
cmd = [
|
|
96
|
+
"npx",
|
|
97
|
+
"-y",
|
|
98
|
+
"openapi-to-postmanv2",
|
|
99
|
+
"-s",
|
|
100
|
+
openapi,
|
|
101
|
+
"-o",
|
|
102
|
+
out,
|
|
103
|
+
]
|
|
104
|
+
if _parse_bool(dry_run, True):
|
|
105
|
+
_echo(cmd)
|
|
106
|
+
return
|
|
107
|
+
subprocess.check_call(cmd)
|
|
108
|
+
typer.secho(f"Postman collection generated → {out}", fg=typer.colors.GREEN)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def register(root: typer.Typer):
|
|
112
|
+
root.add_typer(app, name="sdk")
|
|
@@ -25,7 +25,9 @@ def candidate_cmds(root: Path, prog: str, argv: List[str]) -> List[List[str]]:
|
|
|
25
25
|
cmds.append([prog, *argv])
|
|
26
26
|
|
|
27
27
|
py = shutil.which("python3") or shutil.which("python") or "python"
|
|
28
|
-
module =
|
|
28
|
+
module = (
|
|
29
|
+
prog.replace("-", "_") + ".cli_shim"
|
|
30
|
+
) # e.g., svc-infra -> svc_infra.cli_shim
|
|
29
31
|
cmds.append([py, "-m", module, *argv])
|
|
30
32
|
|
|
31
33
|
return cmds
|
|
@@ -54,4 +56,6 @@ async def run_from_root(root: Path, prog: str, argv: List[str]) -> str:
|
|
|
54
56
|
except Exception as e:
|
|
55
57
|
last_exc = e
|
|
56
58
|
continue
|
|
57
|
-
raise RuntimeError(
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"All runners failed in {root} for: {prog} {' '.join(argv)}"
|
|
61
|
+
) from last_exc
|
svc_infra/data/add.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Callable, Iterable, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
|
|
8
|
+
from svc_infra.cli.cmds.db.sql.alembic_cmds import cmd_setup_and_migrate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_data_lifecycle(
|
|
12
|
+
app: FastAPI,
|
|
13
|
+
*,
|
|
14
|
+
auto_migrate: bool = True,
|
|
15
|
+
database_url: str | None = None,
|
|
16
|
+
discover_packages: Optional[list[str]] = None,
|
|
17
|
+
with_payments: bool | None = None,
|
|
18
|
+
on_load_fixtures: Optional[Callable[[], None]] = None,
|
|
19
|
+
retention_jobs: Optional[Iterable[Callable[[], None]]] = None,
|
|
20
|
+
erasure_job: Optional[Callable[[str], None]] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Wire data lifecycle conveniences:
|
|
24
|
+
|
|
25
|
+
- auto_migrate: run end-to-end Alembic setup-and-migrate on startup (idempotent).
|
|
26
|
+
- on_load_fixtures: optional callback to load reference/fixture data once at startup.
|
|
27
|
+
- retention_jobs: optional list of callables to register purge tasks (scheduler integration is external).
|
|
28
|
+
- erasure_job: optional callable to trigger a GDPR erasure workflow for a given principal ID.
|
|
29
|
+
|
|
30
|
+
This helper is intentionally minimal: it coordinates existing building blocks
|
|
31
|
+
and offers extension points. Jobs should be scheduled using svc_infra.jobs helpers.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def _run_lifecycle() -> None:
|
|
35
|
+
# Startup
|
|
36
|
+
if auto_migrate:
|
|
37
|
+
cmd_setup_and_migrate(
|
|
38
|
+
database_url=database_url,
|
|
39
|
+
overwrite_scaffold=False,
|
|
40
|
+
create_db_if_missing=True,
|
|
41
|
+
create_followup_revision=True,
|
|
42
|
+
initial_message="initial schema",
|
|
43
|
+
followup_message="autogen",
|
|
44
|
+
discover_packages=discover_packages,
|
|
45
|
+
with_payments=with_payments if with_payments is not None else False,
|
|
46
|
+
)
|
|
47
|
+
if on_load_fixtures:
|
|
48
|
+
res = on_load_fixtures()
|
|
49
|
+
if inspect.isawaitable(res):
|
|
50
|
+
await res
|
|
51
|
+
|
|
52
|
+
app.add_event_handler("startup", _run_lifecycle)
|
|
53
|
+
|
|
54
|
+
# Store optional jobs on app.state for external schedulers to discover/register.
|
|
55
|
+
if retention_jobs is not None:
|
|
56
|
+
app.state.data_retention_jobs = list(retention_jobs)
|
|
57
|
+
if erasure_job is not None:
|
|
58
|
+
app.state.data_erasure_job = erasure_job
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["add_data_lifecycle"]
|