kctl-api 0.2.0__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.
- kctl_api/__init__.py +3 -0
- kctl_api/__main__.py +5 -0
- kctl_api/cli.py +238 -0
- kctl_api/commands/__init__.py +1 -0
- kctl_api/commands/ai.py +250 -0
- kctl_api/commands/aliases.py +84 -0
- kctl_api/commands/apps.py +172 -0
- kctl_api/commands/auth.py +313 -0
- kctl_api/commands/automation.py +242 -0
- kctl_api/commands/build_cmd.py +87 -0
- kctl_api/commands/clean.py +182 -0
- kctl_api/commands/config_cmd.py +443 -0
- kctl_api/commands/dashboard.py +139 -0
- kctl_api/commands/db.py +599 -0
- kctl_api/commands/deploy.py +84 -0
- kctl_api/commands/deps.py +289 -0
- kctl_api/commands/dev.py +136 -0
- kctl_api/commands/docker_cmd.py +252 -0
- kctl_api/commands/doctor_cmd.py +286 -0
- kctl_api/commands/env.py +289 -0
- kctl_api/commands/files.py +250 -0
- kctl_api/commands/fmt_cmd.py +58 -0
- kctl_api/commands/health.py +479 -0
- kctl_api/commands/jobs.py +169 -0
- kctl_api/commands/lint_cmd.py +81 -0
- kctl_api/commands/logs.py +258 -0
- kctl_api/commands/marketplace.py +316 -0
- kctl_api/commands/monitor_cmd.py +243 -0
- kctl_api/commands/notifications.py +132 -0
- kctl_api/commands/odoo_proxy.py +182 -0
- kctl_api/commands/openapi.py +299 -0
- kctl_api/commands/perf.py +307 -0
- kctl_api/commands/rate_limit.py +223 -0
- kctl_api/commands/realtime.py +100 -0
- kctl_api/commands/redis_cmd.py +609 -0
- kctl_api/commands/routes_cmd.py +277 -0
- kctl_api/commands/saas.py +145 -0
- kctl_api/commands/scaffold.py +362 -0
- kctl_api/commands/security_cmd.py +350 -0
- kctl_api/commands/services.py +191 -0
- kctl_api/commands/shell.py +197 -0
- kctl_api/commands/skill_cmd.py +58 -0
- kctl_api/commands/streams.py +309 -0
- kctl_api/commands/stripe_cmd.py +105 -0
- kctl_api/commands/tenant_ai.py +169 -0
- kctl_api/commands/test_cmd.py +95 -0
- kctl_api/commands/users.py +302 -0
- kctl_api/commands/webhooks.py +56 -0
- kctl_api/commands/workflows.py +127 -0
- kctl_api/commands/ws.py +323 -0
- kctl_api/core/__init__.py +1 -0
- kctl_api/core/async_client.py +120 -0
- kctl_api/core/callbacks.py +88 -0
- kctl_api/core/client.py +190 -0
- kctl_api/core/config.py +260 -0
- kctl_api/core/db.py +65 -0
- kctl_api/core/exceptions.py +43 -0
- kctl_api/core/output.py +5 -0
- kctl_api/core/plugins.py +26 -0
- kctl_api/core/redis.py +35 -0
- kctl_api/core/resolve.py +47 -0
- kctl_api/core/utils.py +109 -0
- kctl_api-0.2.0.dist-info/METADATA +34 -0
- kctl_api-0.2.0.dist-info/RECORD +66 -0
- kctl_api-0.2.0.dist-info/WHEEL +4 -0
- kctl_api-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""OpenAPI spec management commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Export, validate, diff, and analyze the API specification.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from kctl_api.core.callbacks import AppContext
|
|
15
|
+
from kctl_api.core.utils import find_project_root
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="openapi", help="OpenAPI spec — export, validate, diff, breaking changes.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fetch_spec(actx: AppContext) -> dict:
|
|
21
|
+
"""Fetch OpenAPI spec from running API or raise."""
|
|
22
|
+
data = actx.client.get("/openapi.json")
|
|
23
|
+
if not data:
|
|
24
|
+
raise RuntimeError("Empty response from /openapi.json")
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# export
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
@app.command()
|
|
32
|
+
def export(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
fmt: Annotated[str, typer.Option("--format", help="Output format: json or yaml.")] = "json",
|
|
35
|
+
output: Annotated[str | None, typer.Option("--output", "-o", help="Write to file instead of stdout.")] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Fetch /openapi.json from running API and export as JSON or YAML."""
|
|
38
|
+
actx: AppContext = ctx.obj
|
|
39
|
+
out = actx.output
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
spec = _fetch_spec(actx)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
45
|
+
raise typer.Exit(1) from None
|
|
46
|
+
|
|
47
|
+
if fmt.lower() == "yaml":
|
|
48
|
+
try:
|
|
49
|
+
import yaml # type: ignore[import-untyped]
|
|
50
|
+
|
|
51
|
+
content = yaml.dump(spec, allow_unicode=True, sort_keys=False)
|
|
52
|
+
except ImportError:
|
|
53
|
+
out.error("PyYAML not installed. Run: uv add pyyaml")
|
|
54
|
+
raise typer.Exit(1) from None
|
|
55
|
+
else:
|
|
56
|
+
content = json.dumps(spec, indent=2)
|
|
57
|
+
|
|
58
|
+
if output:
|
|
59
|
+
import pathlib
|
|
60
|
+
|
|
61
|
+
pathlib.Path(output).write_text(content)
|
|
62
|
+
out.success(f"Spec written to {output}")
|
|
63
|
+
else:
|
|
64
|
+
typer.echo(content)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# validate
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
@app.command()
|
|
71
|
+
def validate(ctx: typer.Context) -> None:
|
|
72
|
+
"""Check spec completeness — missing descriptions, examples, error schemas."""
|
|
73
|
+
actx: AppContext = ctx.obj
|
|
74
|
+
out = actx.output
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
spec = _fetch_spec(actx)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
80
|
+
raise typer.Exit(1) from None
|
|
81
|
+
|
|
82
|
+
issues: list[dict] = []
|
|
83
|
+
paths = spec.get("paths", {})
|
|
84
|
+
|
|
85
|
+
for path, path_item in paths.items():
|
|
86
|
+
for method, operation in path_item.items():
|
|
87
|
+
if method not in ("get", "post", "put", "patch", "delete", "head", "options"):
|
|
88
|
+
continue
|
|
89
|
+
# Missing summary/description
|
|
90
|
+
if not operation.get("summary") and not operation.get("description"):
|
|
91
|
+
issues.append({"path": path, "method": method.upper(), "issue": "Missing summary/description"})
|
|
92
|
+
|
|
93
|
+
# Check for error response schemas (400/422/500)
|
|
94
|
+
responses = operation.get("responses", {})
|
|
95
|
+
has_error_schema = any(str(code) in responses for code in (400, 401, 403, 404, 422, 500))
|
|
96
|
+
if not has_error_schema:
|
|
97
|
+
issues.append({"path": path, "method": method.upper(), "issue": "No error response schema"})
|
|
98
|
+
|
|
99
|
+
# Check request body has examples
|
|
100
|
+
req_body = operation.get("requestBody", {})
|
|
101
|
+
if req_body:
|
|
102
|
+
content = req_body.get("content", {})
|
|
103
|
+
for media_type, media_obj in content.items():
|
|
104
|
+
if not media_obj.get("example") and not media_obj.get("examples"):
|
|
105
|
+
issues.append(
|
|
106
|
+
{
|
|
107
|
+
"path": path,
|
|
108
|
+
"method": method.upper(),
|
|
109
|
+
"issue": f"Request body missing example ({media_type})",
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if not issues:
|
|
114
|
+
out.success(f"Spec validation passed — {len(paths)} paths checked, no issues found.")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
rows = [[i["method"], i["path"], i["issue"]] for i in issues]
|
|
118
|
+
out.table(
|
|
119
|
+
title=f"Spec Issues ({len(issues)})",
|
|
120
|
+
columns=[("Method", "bold"), ("Path", ""), ("Issue", "yellow")],
|
|
121
|
+
rows=rows,
|
|
122
|
+
data_for_json=issues,
|
|
123
|
+
)
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# diff
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
@app.command()
|
|
131
|
+
def diff(
|
|
132
|
+
ctx: typer.Context,
|
|
133
|
+
base: Annotated[str, typer.Option("--base", help="Git ref to compare against.")] = "main",
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Diff OpenAPI spec between current state and a git ref."""
|
|
136
|
+
actx: AppContext = ctx.obj
|
|
137
|
+
out = actx.output
|
|
138
|
+
|
|
139
|
+
root = find_project_root()
|
|
140
|
+
|
|
141
|
+
# Export current spec to temp file
|
|
142
|
+
try:
|
|
143
|
+
current_spec = _fetch_spec(actx)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
out.error(f"Failed to fetch current spec: {e}")
|
|
146
|
+
raise typer.Exit(1) from None
|
|
147
|
+
|
|
148
|
+
import pathlib
|
|
149
|
+
import tempfile
|
|
150
|
+
|
|
151
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
152
|
+
json.dump(current_spec, f, indent=2)
|
|
153
|
+
current_file = f.name
|
|
154
|
+
|
|
155
|
+
# Get spec from base ref
|
|
156
|
+
base_file = pathlib.Path(tempfile.mktemp(suffix=".json"))
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
["git", "show", f"{base}:apps/api/openapi.json"],
|
|
159
|
+
cwd=str(root),
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
)
|
|
163
|
+
if result.returncode != 0:
|
|
164
|
+
out.warn(f"Could not find spec at {base}:apps/api/openapi.json — showing current spec only.")
|
|
165
|
+
out.info("To generate a base spec: kctl-api openapi export -o apps/api/openapi.json && git commit")
|
|
166
|
+
raise typer.Exit(0)
|
|
167
|
+
|
|
168
|
+
base_file.write_text(result.stdout)
|
|
169
|
+
|
|
170
|
+
subprocess.run(["diff", "-u", str(base_file), current_file], capture_output=False)
|
|
171
|
+
|
|
172
|
+
pathlib.Path(current_file).unlink(missing_ok=True)
|
|
173
|
+
base_file.unlink(missing_ok=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# breaking
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
@app.command()
|
|
180
|
+
def breaking(
|
|
181
|
+
ctx: typer.Context,
|
|
182
|
+
base: Annotated[str, typer.Option("--base", help="Git ref to compare against.")] = "main",
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Detect breaking changes — removed endpoints, changed types, removed fields."""
|
|
185
|
+
actx: AppContext = ctx.obj
|
|
186
|
+
out = actx.output
|
|
187
|
+
|
|
188
|
+
root = find_project_root()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
current_spec = _fetch_spec(actx)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
out.error(f"Failed to fetch current spec: {e}")
|
|
194
|
+
raise typer.Exit(1) from None
|
|
195
|
+
|
|
196
|
+
# Attempt to load base spec from git
|
|
197
|
+
result = subprocess.run(
|
|
198
|
+
["git", "show", f"{base}:apps/api/openapi.json"],
|
|
199
|
+
cwd=str(root),
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
)
|
|
203
|
+
if result.returncode != 0:
|
|
204
|
+
out.warn(f"No base spec found at {base}:apps/api/openapi.json")
|
|
205
|
+
out.info("Commit your openapi.json to enable breaking change detection.")
|
|
206
|
+
raise typer.Exit(0)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
base_spec = json.loads(result.stdout)
|
|
210
|
+
except json.JSONDecodeError as e:
|
|
211
|
+
out.error(f"Could not parse base spec: {e}")
|
|
212
|
+
raise typer.Exit(1) from None
|
|
213
|
+
|
|
214
|
+
changes: list[dict] = []
|
|
215
|
+
base_paths = base_spec.get("paths", {})
|
|
216
|
+
curr_paths = current_spec.get("paths", {})
|
|
217
|
+
|
|
218
|
+
# Removed endpoints
|
|
219
|
+
for path in base_paths:
|
|
220
|
+
if path not in curr_paths:
|
|
221
|
+
changes.append({"severity": "BREAKING", "type": "Removed endpoint", "detail": path})
|
|
222
|
+
else:
|
|
223
|
+
for method in base_paths[path]:
|
|
224
|
+
if method not in ("get", "post", "put", "patch", "delete"):
|
|
225
|
+
continue
|
|
226
|
+
if method not in curr_paths.get(path, {}):
|
|
227
|
+
changes.append(
|
|
228
|
+
{
|
|
229
|
+
"severity": "BREAKING",
|
|
230
|
+
"type": "Removed method",
|
|
231
|
+
"detail": f"{method.upper()} {path}",
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# New endpoints (informational)
|
|
236
|
+
for path in curr_paths:
|
|
237
|
+
if path not in base_paths:
|
|
238
|
+
changes.append({"severity": "INFO", "type": "New endpoint", "detail": path})
|
|
239
|
+
|
|
240
|
+
if not changes:
|
|
241
|
+
out.success("No breaking changes detected.")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
rows = [[c["severity"], c["type"], c["detail"]] for c in changes]
|
|
245
|
+
out.table(
|
|
246
|
+
title=f"Breaking Change Analysis ({len(changes)} changes)",
|
|
247
|
+
columns=[("Severity", "bold"), ("Type", ""), ("Detail", "")],
|
|
248
|
+
rows=rows,
|
|
249
|
+
data_for_json=changes,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
has_breaking = any(c["severity"] == "BREAKING" for c in changes)
|
|
253
|
+
if has_breaking:
|
|
254
|
+
out.error("Breaking changes detected!")
|
|
255
|
+
raise typer.Exit(1)
|
|
256
|
+
else:
|
|
257
|
+
out.warn("Non-breaking changes only.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# clients
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
@app.command()
|
|
264
|
+
def clients(ctx: typer.Context) -> None:
|
|
265
|
+
"""Show information about generating client SDKs from the OpenAPI spec."""
|
|
266
|
+
actx: AppContext = ctx.obj
|
|
267
|
+
out = actx.output
|
|
268
|
+
|
|
269
|
+
url = actx.client.base_url.rstrip("/")
|
|
270
|
+
spec_url = f"{url}/openapi.json"
|
|
271
|
+
|
|
272
|
+
out.header("Client SDK Generation")
|
|
273
|
+
out.text("")
|
|
274
|
+
out.text("[bold]OpenAPI spec URL:[/bold]")
|
|
275
|
+
out.text(f" {spec_url}")
|
|
276
|
+
out.text("")
|
|
277
|
+
out.text("[bold]Generate TypeScript client (openapi-typescript-codegen):[/bold]")
|
|
278
|
+
out.text(f" npx openapi-typescript-codegen --input {spec_url} --output src/api --client axios")
|
|
279
|
+
out.text("")
|
|
280
|
+
out.text("[bold]Generate Python client (openapi-python-client):[/bold]")
|
|
281
|
+
out.text(f" uvx openapi-python-client generate --url {spec_url}")
|
|
282
|
+
out.text("")
|
|
283
|
+
out.text("[bold]Export spec first:[/bold]")
|
|
284
|
+
out.text(" kctl-api openapi export --format json -o openapi.json")
|
|
285
|
+
out.text(" kctl-api openapi export --format yaml -o openapi.yaml")
|
|
286
|
+
out.text("")
|
|
287
|
+
out.text("[bold]Validate before generating:[/bold]")
|
|
288
|
+
out.text(" kctl-api openapi validate")
|
|
289
|
+
|
|
290
|
+
if actx.json_mode:
|
|
291
|
+
out.raw_json(
|
|
292
|
+
{
|
|
293
|
+
"spec_url": spec_url,
|
|
294
|
+
"tools": {
|
|
295
|
+
"typescript": f"npx openapi-typescript-codegen --input {spec_url} --output src/api --client axios",
|
|
296
|
+
"python": f"uvx openapi-python-client generate --url {spec_url}",
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Performance testing commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Benchmarking, profiling, and latency analysis.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import statistics
|
|
9
|
+
import time
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from kctl_api.core.callbacks import AppContext
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="perf", help="Performance — bench, profile, latency, endpoints.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# bench
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
@app.command()
|
|
23
|
+
def bench(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
endpoint: Annotated[str, typer.Argument(help="Endpoint path to benchmark (e.g. /api/v1/health).")],
|
|
26
|
+
rps: Annotated[int, typer.Option("--rps", help="Target requests per second.")] = 10,
|
|
27
|
+
duration: Annotated[int, typer.Option("--duration", help="Test duration in seconds.")] = 10,
|
|
28
|
+
method: Annotated[str, typer.Option("--method", "-X", help="HTTP method.")] = "GET",
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Load test an endpoint — target RPS for a duration."""
|
|
31
|
+
actx: AppContext = ctx.obj
|
|
32
|
+
out = actx.output
|
|
33
|
+
|
|
34
|
+
total_requests = rps * duration
|
|
35
|
+
interval = 1.0 / rps
|
|
36
|
+
|
|
37
|
+
out.info(f"Benchmarking {method} {endpoint} — {rps} RPS for {duration}s ({total_requests} requests) ...")
|
|
38
|
+
|
|
39
|
+
latencies: list[float] = []
|
|
40
|
+
errors: list[str] = []
|
|
41
|
+
status_counts: dict[int, int] = {}
|
|
42
|
+
|
|
43
|
+
start_time = time.monotonic()
|
|
44
|
+
next_send = start_time
|
|
45
|
+
|
|
46
|
+
for i in range(total_requests):
|
|
47
|
+
# Rate control
|
|
48
|
+
now = time.monotonic()
|
|
49
|
+
if now < next_send:
|
|
50
|
+
time.sleep(next_send - now)
|
|
51
|
+
next_send = time.monotonic() + interval
|
|
52
|
+
|
|
53
|
+
req_start = time.monotonic()
|
|
54
|
+
try:
|
|
55
|
+
response = actx.client.get_raw(endpoint)
|
|
56
|
+
|
|
57
|
+
elapsed_ms = (time.monotonic() - req_start) * 1000
|
|
58
|
+
latencies.append(elapsed_ms)
|
|
59
|
+
status = response.status_code
|
|
60
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
61
|
+
except Exception as e:
|
|
62
|
+
elapsed_ms = (time.monotonic() - req_start) * 1000
|
|
63
|
+
latencies.append(elapsed_ms)
|
|
64
|
+
errors.append(str(e)[:60])
|
|
65
|
+
|
|
66
|
+
# Progress every 10%
|
|
67
|
+
if (i + 1) % max(total_requests // 10, 1) == 0:
|
|
68
|
+
pct = round((i + 1) / total_requests * 100)
|
|
69
|
+
out.info(f" Progress: {pct}% ({i + 1}/{total_requests})")
|
|
70
|
+
|
|
71
|
+
total_time = time.monotonic() - start_time
|
|
72
|
+
actual_rps = round(len(latencies) / max(total_time, 0.001))
|
|
73
|
+
|
|
74
|
+
if not latencies:
|
|
75
|
+
out.error("No requests completed.")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
sorted_lat = sorted(latencies)
|
|
79
|
+
p50 = statistics.median(sorted_lat)
|
|
80
|
+
p95 = sorted_lat[int(len(sorted_lat) * 0.95)]
|
|
81
|
+
p99 = sorted_lat[int(len(sorted_lat) * 0.99)]
|
|
82
|
+
avg = statistics.mean(sorted_lat)
|
|
83
|
+
min_lat = min(sorted_lat)
|
|
84
|
+
max_lat = max(sorted_lat)
|
|
85
|
+
|
|
86
|
+
out.text("")
|
|
87
|
+
out.header(f"Benchmark Results: {endpoint}")
|
|
88
|
+
out.text(f" Duration: {round(total_time, 1)}s")
|
|
89
|
+
out.text(f" Requests: {len(latencies)}")
|
|
90
|
+
out.text(f" Actual RPS: {actual_rps}")
|
|
91
|
+
out.text(f" Errors: [{'red' if errors else 'green'}]{len(errors)}[/{'red' if errors else 'green'}]")
|
|
92
|
+
out.text("")
|
|
93
|
+
out.text(" Latency:")
|
|
94
|
+
out.text(f" min: {round(min_lat, 1)}ms")
|
|
95
|
+
out.text(f" avg: {round(avg, 1)}ms")
|
|
96
|
+
out.text(f" p50: {round(p50, 1)}ms")
|
|
97
|
+
out.text(f" p95: {round(p95, 1)}ms")
|
|
98
|
+
out.text(f" p99: {round(p99, 1)}ms")
|
|
99
|
+
out.text(f" max: {round(max_lat, 1)}ms")
|
|
100
|
+
out.text("")
|
|
101
|
+
if status_counts:
|
|
102
|
+
out.text(" Status codes: " + ", ".join(f"{k}:{v}" for k, v in sorted(status_counts.items())))
|
|
103
|
+
|
|
104
|
+
if actx.json_mode:
|
|
105
|
+
out.raw_json(
|
|
106
|
+
{
|
|
107
|
+
"endpoint": endpoint,
|
|
108
|
+
"method": method,
|
|
109
|
+
"duration_s": round(total_time, 2),
|
|
110
|
+
"requests": len(latencies),
|
|
111
|
+
"actual_rps": actual_rps,
|
|
112
|
+
"errors": len(errors),
|
|
113
|
+
"latency_ms": {
|
|
114
|
+
"min": round(min_lat, 1),
|
|
115
|
+
"avg": round(avg, 1),
|
|
116
|
+
"p50": round(p50, 1),
|
|
117
|
+
"p95": round(p95, 1),
|
|
118
|
+
"p99": round(p99, 1),
|
|
119
|
+
"max": round(max_lat, 1),
|
|
120
|
+
},
|
|
121
|
+
"status_counts": status_counts,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# profile
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
@app.command()
|
|
130
|
+
def profile(
|
|
131
|
+
ctx: typer.Context,
|
|
132
|
+
endpoint: Annotated[str, typer.Argument(help="Endpoint path to profile.")],
|
|
133
|
+
samples: Annotated[int, typer.Option("--samples", "-n", help="Number of samples.")] = 10,
|
|
134
|
+
method: Annotated[str, typer.Option("--method", "-X", help="HTTP method.")] = "GET",
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Profile endpoint execution — timing breakdown over N samples."""
|
|
137
|
+
actx: AppContext = ctx.obj
|
|
138
|
+
out = actx.output
|
|
139
|
+
|
|
140
|
+
out.info(f"Profiling {method} {endpoint} — {samples} samples ...")
|
|
141
|
+
|
|
142
|
+
latencies: list[float] = []
|
|
143
|
+
ttfbs: list[float] = [] # time to first byte (approximated)
|
|
144
|
+
|
|
145
|
+
for i in range(samples):
|
|
146
|
+
req_start = time.monotonic()
|
|
147
|
+
try:
|
|
148
|
+
actx.client.get_raw(endpoint)
|
|
149
|
+
elapsed_ms = (time.monotonic() - req_start) * 1000
|
|
150
|
+
latencies.append(elapsed_ms)
|
|
151
|
+
ttfbs.append(elapsed_ms) # httpx doesn't expose TTFB separately without streaming
|
|
152
|
+
except Exception as e:
|
|
153
|
+
out.warn(f"Sample {i + 1} failed: {e}")
|
|
154
|
+
|
|
155
|
+
if not latencies:
|
|
156
|
+
out.error("All samples failed.")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
rows = [[str(i + 1), f"{round(lat, 1)}ms"] for i, lat in enumerate(latencies)]
|
|
160
|
+
out.table(
|
|
161
|
+
title=f"Profile: {endpoint} ({len(latencies)} samples)",
|
|
162
|
+
columns=[("Sample", ""), ("Latency", "")],
|
|
163
|
+
rows=rows,
|
|
164
|
+
data_for_json=[{"sample": i + 1, "latency_ms": round(lat, 1)} for i, lat in enumerate(latencies)],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if len(latencies) > 1:
|
|
168
|
+
out.text("")
|
|
169
|
+
out.text(f" avg: {round(statistics.mean(latencies), 1)}ms stdev: {round(statistics.stdev(latencies), 1)}ms")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# latency
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
@app.command()
|
|
176
|
+
def latency(
|
|
177
|
+
ctx: typer.Context,
|
|
178
|
+
endpoint: Annotated[str | None, typer.Argument(help="Endpoint to check (default: /api/v1/health).")] = None,
|
|
179
|
+
samples: Annotated[int, typer.Option("--samples", "-n", help="Number of samples.")] = 20,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Show latency percentiles for an endpoint."""
|
|
182
|
+
actx: AppContext = ctx.obj
|
|
183
|
+
out = actx.output
|
|
184
|
+
|
|
185
|
+
target = endpoint or "/api/v1/health"
|
|
186
|
+
out.info(f"Measuring latency: {target} ({samples} samples) ...")
|
|
187
|
+
|
|
188
|
+
latencies: list[float] = []
|
|
189
|
+
for _ in range(samples):
|
|
190
|
+
start = time.monotonic()
|
|
191
|
+
try:
|
|
192
|
+
actx.client.get_raw(target)
|
|
193
|
+
latencies.append((time.monotonic() - start) * 1000)
|
|
194
|
+
except Exception:
|
|
195
|
+
latencies.append(-1.0)
|
|
196
|
+
|
|
197
|
+
valid = [lat for lat in latencies if lat >= 0]
|
|
198
|
+
if not valid:
|
|
199
|
+
out.error("All requests failed.")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
|
|
202
|
+
sorted_lat = sorted(valid)
|
|
203
|
+
percentiles = [50, 75, 90, 95, 99]
|
|
204
|
+
|
|
205
|
+
rows = [
|
|
206
|
+
[f"p{p}", f"{round(sorted_lat[min(int(len(sorted_lat) * p / 100), len(sorted_lat) - 1)], 1)}ms"]
|
|
207
|
+
for p in percentiles
|
|
208
|
+
]
|
|
209
|
+
rows.insert(0, ["min", f"{round(min(sorted_lat), 1)}ms"])
|
|
210
|
+
rows.insert(1, ["avg", f"{round(statistics.mean(sorted_lat), 1)}ms"])
|
|
211
|
+
rows.append(["max", f"{round(max(sorted_lat), 1)}ms"])
|
|
212
|
+
|
|
213
|
+
failed = len(latencies) - len(valid)
|
|
214
|
+
out.table(
|
|
215
|
+
title=f"Latency Percentiles: {target} ({len(valid)} samples, {failed} failed)",
|
|
216
|
+
columns=[("Percentile", "bold"), ("Latency", "")],
|
|
217
|
+
rows=rows,
|
|
218
|
+
data_for_json={
|
|
219
|
+
"endpoint": target,
|
|
220
|
+
"samples": len(valid),
|
|
221
|
+
"failed": failed,
|
|
222
|
+
"percentiles": {
|
|
223
|
+
f"p{p}": round(sorted_lat[min(int(len(sorted_lat) * p / 100), len(sorted_lat) - 1)], 1)
|
|
224
|
+
for p in percentiles
|
|
225
|
+
},
|
|
226
|
+
"min": round(min(sorted_lat), 1),
|
|
227
|
+
"avg": round(statistics.mean(sorted_lat), 1),
|
|
228
|
+
"max": round(max(sorted_lat), 1),
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# endpoints
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
@app.command()
|
|
237
|
+
def endpoints(
|
|
238
|
+
ctx: typer.Context,
|
|
239
|
+
samples: Annotated[int, typer.Option("--samples", "-n", help="Samples per endpoint.")] = 3,
|
|
240
|
+
tag: Annotated[str | None, typer.Option("--tag", help="Filter by OpenAPI tag.")] = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Measure response times for all GET endpoints from the OpenAPI spec."""
|
|
243
|
+
actx: AppContext = ctx.obj
|
|
244
|
+
out = actx.output
|
|
245
|
+
|
|
246
|
+
# Fetch spec
|
|
247
|
+
try:
|
|
248
|
+
spec_data = actx.client.get("/openapi.json")
|
|
249
|
+
if not spec_data:
|
|
250
|
+
raise RuntimeError("Empty spec")
|
|
251
|
+
except Exception as e:
|
|
252
|
+
out.error(f"Failed to fetch spec: {e}")
|
|
253
|
+
raise typer.Exit(1) from None
|
|
254
|
+
|
|
255
|
+
# Collect GET endpoints
|
|
256
|
+
get_endpoints: list[str] = []
|
|
257
|
+
for path, path_item in spec_data.get("paths", {}).items():
|
|
258
|
+
if "get" not in path_item:
|
|
259
|
+
continue
|
|
260
|
+
operation = path_item["get"]
|
|
261
|
+
# Skip paths with parameters (can't call them without values)
|
|
262
|
+
if "{" in path:
|
|
263
|
+
continue
|
|
264
|
+
if tag and tag not in operation.get("tags", []):
|
|
265
|
+
continue
|
|
266
|
+
get_endpoints.append(path)
|
|
267
|
+
|
|
268
|
+
if not get_endpoints:
|
|
269
|
+
out.warn("No GET endpoints without path parameters found.")
|
|
270
|
+
raise typer.Exit(0)
|
|
271
|
+
|
|
272
|
+
out.info(f"Testing {len(get_endpoints)} GET endpoints ({samples} samples each) ...")
|
|
273
|
+
|
|
274
|
+
results: list[dict] = []
|
|
275
|
+
for ep in get_endpoints:
|
|
276
|
+
ep_latencies: list[float] = []
|
|
277
|
+
last_status = 0
|
|
278
|
+
for _ in range(samples):
|
|
279
|
+
start = time.monotonic()
|
|
280
|
+
try:
|
|
281
|
+
resp = actx.client.get_raw(ep)
|
|
282
|
+
last_status = resp.status_code
|
|
283
|
+
ep_latencies.append((time.monotonic() - start) * 1000)
|
|
284
|
+
except Exception:
|
|
285
|
+
ep_latencies.append(-1.0)
|
|
286
|
+
|
|
287
|
+
valid = [lat for lat in ep_latencies if lat >= 0]
|
|
288
|
+
avg = round(statistics.mean(valid), 1) if valid else -1
|
|
289
|
+
|
|
290
|
+
results.append(
|
|
291
|
+
{
|
|
292
|
+
"endpoint": ep,
|
|
293
|
+
"status": last_status,
|
|
294
|
+
"avg_ms": avg,
|
|
295
|
+
"samples": len(valid),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
results.sort(key=lambda x: -x["avg_ms"])
|
|
300
|
+
|
|
301
|
+
rows = [[r["endpoint"], str(r["status"]), f"{r['avg_ms']}ms", str(r["samples"])] for r in results]
|
|
302
|
+
out.table(
|
|
303
|
+
title="Endpoint Response Times (slowest first)",
|
|
304
|
+
columns=[("Endpoint", ""), ("Status", ""), ("Avg Latency", "bold"), ("Samples", "")],
|
|
305
|
+
rows=rows,
|
|
306
|
+
data_for_json=results,
|
|
307
|
+
)
|