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
|
@@ -0,0 +1,88 @@
|
|
|
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, cast
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from sqlalchemy.engine import Engine
|
|
13
|
+
|
|
14
|
+
from svc_infra.db.sql.utils import build_engine
|
|
15
|
+
|
|
16
|
+
try: # SQLAlchemy async extras are optional
|
|
17
|
+
import sqlalchemy.ext.asyncio as sa_async
|
|
18
|
+
except Exception: # pragma: no cover - fallback when async extras unavailable
|
|
19
|
+
sa_async = None # type: ignore[assignment]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def export_tenant(
|
|
23
|
+
table: str = typer.Argument(
|
|
24
|
+
..., help="Qualified table name to export (e.g., public.items)"
|
|
25
|
+
),
|
|
26
|
+
tenant_id: str = typer.Option(
|
|
27
|
+
..., "--tenant-id", help="Tenant id value to filter by."
|
|
28
|
+
),
|
|
29
|
+
tenant_field: str = typer.Option(
|
|
30
|
+
"tenant_id", help="Column name for tenant id filter."
|
|
31
|
+
),
|
|
32
|
+
output: Optional[Path] = typer.Option(
|
|
33
|
+
None, "--output", help="Output file; defaults to stdout."
|
|
34
|
+
),
|
|
35
|
+
limit: Optional[int] = typer.Option(None, help="Max rows to export."),
|
|
36
|
+
database_url: Optional[str] = typer.Option(
|
|
37
|
+
None, "--database-url", help="Overrides env SQL_URL for this command."
|
|
38
|
+
),
|
|
39
|
+
):
|
|
40
|
+
"""Export rows for a tenant from a given SQL table as JSON array."""
|
|
41
|
+
if database_url:
|
|
42
|
+
os.environ["SQL_URL"] = database_url
|
|
43
|
+
|
|
44
|
+
url = os.getenv("SQL_URL")
|
|
45
|
+
if not url:
|
|
46
|
+
typer.echo("SQL_URL is required (or pass --database-url)", err=True)
|
|
47
|
+
raise typer.Exit(code=2)
|
|
48
|
+
|
|
49
|
+
engine = build_engine(url)
|
|
50
|
+
rows: list[dict[str, Any]]
|
|
51
|
+
query = f"SELECT * FROM {table} WHERE {tenant_field} = :tenant_id"
|
|
52
|
+
if limit and limit > 0:
|
|
53
|
+
query += " LIMIT :limit"
|
|
54
|
+
|
|
55
|
+
params: dict[str, Any] = {"tenant_id": tenant_id}
|
|
56
|
+
if limit and limit > 0:
|
|
57
|
+
params["limit"] = int(limit)
|
|
58
|
+
|
|
59
|
+
stmt = text(query)
|
|
60
|
+
|
|
61
|
+
is_async_engine = sa_async is not None and isinstance(engine, sa_async.AsyncEngine)
|
|
62
|
+
|
|
63
|
+
if is_async_engine:
|
|
64
|
+
async_engine = cast(Any, engine)
|
|
65
|
+
|
|
66
|
+
async def _fetch() -> list[dict[str, Any]]:
|
|
67
|
+
async with async_engine.connect() as conn:
|
|
68
|
+
result = await conn.execute(stmt, params)
|
|
69
|
+
return [dict(row) for row in result.mappings()]
|
|
70
|
+
|
|
71
|
+
rows = asyncio.run(_fetch())
|
|
72
|
+
else:
|
|
73
|
+
sync_engine = cast(Engine, engine)
|
|
74
|
+
with sync_engine.connect() as conn:
|
|
75
|
+
result = conn.execute(stmt, params)
|
|
76
|
+
rows = [dict(row) for row in result.mappings()]
|
|
77
|
+
|
|
78
|
+
data = json.dumps(rows, indent=2)
|
|
79
|
+
if output:
|
|
80
|
+
output.write_text(data)
|
|
81
|
+
typer.echo(str(output))
|
|
82
|
+
else:
|
|
83
|
+
sys.stdout.write(data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register(app_root: typer.Typer) -> None:
|
|
87
|
+
# Attach directly to the provided 'sql' group app
|
|
88
|
+
app_root.command("export-tenant")(export_tenant)
|
|
@@ -134,6 +134,6 @@ def cmd_scaffold_schemas(
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
def register(app: typer.Typer) -> None:
|
|
137
|
-
app.command("
|
|
138
|
-
app.command("
|
|
139
|
-
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,142 @@
|
|
|
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(
|
|
126
|
+
"show", help="Show docs for a topic (alternative to dynamic subcommand)"
|
|
127
|
+
)
|
|
128
|
+
def show(topic: str) -> None:
|
|
129
|
+
key = _norm(topic)
|
|
130
|
+
ctx = click.get_current_context()
|
|
131
|
+
dir_to_use = _resolve_docs_dir(ctx)
|
|
132
|
+
fs = _discover_fs_topics(dir_to_use) if dir_to_use else {}
|
|
133
|
+
if key in fs:
|
|
134
|
+
typer.echo(fs[key].read_text(encoding="utf-8", errors="replace"))
|
|
135
|
+
return
|
|
136
|
+
pkg = _discover_pkg_topics()
|
|
137
|
+
if key in pkg:
|
|
138
|
+
typer.echo(pkg[key].read_text(encoding="utf-8", errors="replace"))
|
|
139
|
+
return
|
|
140
|
+
raise typer.BadParameter(f"Unknown topic: {topic}")
|
|
141
|
+
|
|
142
|
+
app.add_typer(docs_app, name="docs")
|
|
@@ -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())
|