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.
Files changed (66) hide show
  1. kctl_api/__init__.py +3 -0
  2. kctl_api/__main__.py +5 -0
  3. kctl_api/cli.py +238 -0
  4. kctl_api/commands/__init__.py +1 -0
  5. kctl_api/commands/ai.py +250 -0
  6. kctl_api/commands/aliases.py +84 -0
  7. kctl_api/commands/apps.py +172 -0
  8. kctl_api/commands/auth.py +313 -0
  9. kctl_api/commands/automation.py +242 -0
  10. kctl_api/commands/build_cmd.py +87 -0
  11. kctl_api/commands/clean.py +182 -0
  12. kctl_api/commands/config_cmd.py +443 -0
  13. kctl_api/commands/dashboard.py +139 -0
  14. kctl_api/commands/db.py +599 -0
  15. kctl_api/commands/deploy.py +84 -0
  16. kctl_api/commands/deps.py +289 -0
  17. kctl_api/commands/dev.py +136 -0
  18. kctl_api/commands/docker_cmd.py +252 -0
  19. kctl_api/commands/doctor_cmd.py +286 -0
  20. kctl_api/commands/env.py +289 -0
  21. kctl_api/commands/files.py +250 -0
  22. kctl_api/commands/fmt_cmd.py +58 -0
  23. kctl_api/commands/health.py +479 -0
  24. kctl_api/commands/jobs.py +169 -0
  25. kctl_api/commands/lint_cmd.py +81 -0
  26. kctl_api/commands/logs.py +258 -0
  27. kctl_api/commands/marketplace.py +316 -0
  28. kctl_api/commands/monitor_cmd.py +243 -0
  29. kctl_api/commands/notifications.py +132 -0
  30. kctl_api/commands/odoo_proxy.py +182 -0
  31. kctl_api/commands/openapi.py +299 -0
  32. kctl_api/commands/perf.py +307 -0
  33. kctl_api/commands/rate_limit.py +223 -0
  34. kctl_api/commands/realtime.py +100 -0
  35. kctl_api/commands/redis_cmd.py +609 -0
  36. kctl_api/commands/routes_cmd.py +277 -0
  37. kctl_api/commands/saas.py +145 -0
  38. kctl_api/commands/scaffold.py +362 -0
  39. kctl_api/commands/security_cmd.py +350 -0
  40. kctl_api/commands/services.py +191 -0
  41. kctl_api/commands/shell.py +197 -0
  42. kctl_api/commands/skill_cmd.py +58 -0
  43. kctl_api/commands/streams.py +309 -0
  44. kctl_api/commands/stripe_cmd.py +105 -0
  45. kctl_api/commands/tenant_ai.py +169 -0
  46. kctl_api/commands/test_cmd.py +95 -0
  47. kctl_api/commands/users.py +302 -0
  48. kctl_api/commands/webhooks.py +56 -0
  49. kctl_api/commands/workflows.py +127 -0
  50. kctl_api/commands/ws.py +323 -0
  51. kctl_api/core/__init__.py +1 -0
  52. kctl_api/core/async_client.py +120 -0
  53. kctl_api/core/callbacks.py +88 -0
  54. kctl_api/core/client.py +190 -0
  55. kctl_api/core/config.py +260 -0
  56. kctl_api/core/db.py +65 -0
  57. kctl_api/core/exceptions.py +43 -0
  58. kctl_api/core/output.py +5 -0
  59. kctl_api/core/plugins.py +26 -0
  60. kctl_api/core/redis.py +35 -0
  61. kctl_api/core/resolve.py +47 -0
  62. kctl_api/core/utils.py +109 -0
  63. kctl_api-0.2.0.dist-info/METADATA +34 -0
  64. kctl_api-0.2.0.dist-info/RECORD +66 -0
  65. kctl_api-0.2.0.dist-info/WHEEL +4 -0
  66. 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
+ )