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