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,127 @@
|
|
|
1
|
+
"""Workflow commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Start, check status, and list workflows.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from kctl_api.core.callbacks import AppContext
|
|
13
|
+
from kctl_api.core.exceptions import APIError, AuthenticationError
|
|
14
|
+
from kctl_api.core.exceptions import ConnectionError as KctlConnectionError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="workflows", help="Workflow management — start, status, list.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# start
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
@app.command()
|
|
23
|
+
def start(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
workflow_name: Annotated[str, typer.Argument(help="Workflow name to start.")],
|
|
26
|
+
data_json: Annotated[str | None, typer.Option("--data", "-d", help="JSON payload for the workflow.")] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Start a new workflow run via POST /api/v1/workflows/start."""
|
|
29
|
+
actx: AppContext = ctx.obj
|
|
30
|
+
out = actx.output
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
payload: dict = {"name": workflow_name}
|
|
35
|
+
if data_json:
|
|
36
|
+
try:
|
|
37
|
+
payload["data"] = json.loads(data_json)
|
|
38
|
+
except json.JSONDecodeError as e:
|
|
39
|
+
out.error(f"Invalid JSON: {e}")
|
|
40
|
+
raise typer.Exit(1) from None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
result = actx.client.post("/api/v1/workflows/start", json=payload)
|
|
44
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
45
|
+
out.error(str(e))
|
|
46
|
+
raise typer.Exit(1) from None
|
|
47
|
+
|
|
48
|
+
out.success(f"Workflow started: {workflow_name}")
|
|
49
|
+
if actx.json_mode:
|
|
50
|
+
out.raw_json(result)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# status
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
@app.command()
|
|
57
|
+
def status(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
run_id: Annotated[str, typer.Argument(help="Workflow run ID.")],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Check workflow run status via GET /api/v1/workflows/{run_id}."""
|
|
62
|
+
actx: AppContext = ctx.obj
|
|
63
|
+
out = actx.output
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
data = actx.client.get(f"/api/v1/workflows/{run_id}")
|
|
67
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
68
|
+
out.error(str(e))
|
|
69
|
+
raise typer.Exit(1) from None
|
|
70
|
+
|
|
71
|
+
if not data:
|
|
72
|
+
out.error(f"Workflow run not found: {run_id}")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
out.detail(
|
|
76
|
+
title=f"Workflow Run: {run_id}",
|
|
77
|
+
sections=[
|
|
78
|
+
("Details", [(k, str(v)) for k, v in data.items()]),
|
|
79
|
+
],
|
|
80
|
+
data_for_json=data,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# list
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
@app.command(name="list")
|
|
88
|
+
def list_workflows(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
|
|
91
|
+
per_page: Annotated[int, typer.Option("--per-page", "-n", help="Items per page.")] = 20,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""List workflow runs via GET /api/v1/workflows."""
|
|
94
|
+
actx: AppContext = ctx.obj
|
|
95
|
+
out = actx.output
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
data = actx.client.get("/api/v1/workflows", params={"page": page, "per_page": per_page})
|
|
99
|
+
except (AuthenticationError, KctlConnectionError, APIError) as e:
|
|
100
|
+
out.error(str(e))
|
|
101
|
+
raise typer.Exit(1) from None
|
|
102
|
+
|
|
103
|
+
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
|
|
104
|
+
total = len(items) if isinstance(data, list) else data.get("total", len(items)) if isinstance(data, dict) else 0
|
|
105
|
+
|
|
106
|
+
rows: list[list[str]] = []
|
|
107
|
+
for w in items:
|
|
108
|
+
rows.append(
|
|
109
|
+
[
|
|
110
|
+
str(w.get("id", "")),
|
|
111
|
+
w.get("name", ""),
|
|
112
|
+
w.get("status", ""),
|
|
113
|
+
str(w.get("created_at", "")),
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
out.table(
|
|
118
|
+
title=f"Workflow Runs (page {page}, {total} total)",
|
|
119
|
+
columns=[
|
|
120
|
+
("ID", "bold"),
|
|
121
|
+
("Name", ""),
|
|
122
|
+
("Status", ""),
|
|
123
|
+
("Created", "dim"),
|
|
124
|
+
],
|
|
125
|
+
rows=rows,
|
|
126
|
+
data_for_json=items,
|
|
127
|
+
)
|
kctl_api/commands/ws.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""WebSocket testing commands for kctl-api.
|
|
2
|
+
|
|
3
|
+
Connect, send, listen, and load test WebSocket endpoints.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
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="ws", help="WebSocket testing — connect, send, listen, load-test.", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_ws_url(actx: AppContext, endpoint: str) -> str:
|
|
20
|
+
"""Convert HTTP base URL to WebSocket URL."""
|
|
21
|
+
base = actx.client.base_url.rstrip("/")
|
|
22
|
+
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
|
|
23
|
+
if not endpoint.startswith("/"):
|
|
24
|
+
endpoint = f"/{endpoint}"
|
|
25
|
+
return f"{ws_base}{endpoint}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# connect
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
@app.command()
|
|
32
|
+
def connect(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
endpoint: Annotated[str, typer.Argument(help="WebSocket endpoint path (e.g. /api/v1/ws/chat).")],
|
|
35
|
+
token: Annotated[str | None, typer.Option("--token", help="Bearer token for auth.")] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Open a WebSocket connection and show the handshake."""
|
|
38
|
+
actx: AppContext = ctx.obj
|
|
39
|
+
out = actx.output
|
|
40
|
+
|
|
41
|
+
ws_url = _build_ws_url(actx, endpoint)
|
|
42
|
+
out.info(f"Connecting to {ws_url} ...")
|
|
43
|
+
|
|
44
|
+
async def _run() -> dict:
|
|
45
|
+
try:
|
|
46
|
+
import websockets # type: ignore[import-untyped]
|
|
47
|
+
except ImportError:
|
|
48
|
+
raise RuntimeError("websockets not installed. Run: uv add websockets") from None
|
|
49
|
+
|
|
50
|
+
headers = {}
|
|
51
|
+
if token:
|
|
52
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
53
|
+
elif actx.client.api_key:
|
|
54
|
+
headers["Authorization"] = f"Bearer {actx.client.api_key}"
|
|
55
|
+
|
|
56
|
+
start = time.monotonic()
|
|
57
|
+
try:
|
|
58
|
+
async with websockets.connect(ws_url, additional_headers=headers) as ws:
|
|
59
|
+
elapsed_ms = round((time.monotonic() - start) * 1000)
|
|
60
|
+
return {
|
|
61
|
+
"url": ws_url,
|
|
62
|
+
"connected": True,
|
|
63
|
+
"handshake_ms": elapsed_ms,
|
|
64
|
+
"subprotocol": ws.subprotocol,
|
|
65
|
+
"remote_address": str(ws.remote_address),
|
|
66
|
+
}
|
|
67
|
+
except Exception as e:
|
|
68
|
+
elapsed_ms = round((time.monotonic() - start) * 1000)
|
|
69
|
+
return {"url": ws_url, "connected": False, "error": str(e), "handshake_ms": elapsed_ms}
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = asyncio.run(_run())
|
|
73
|
+
except RuntimeError as e:
|
|
74
|
+
out.error(str(e))
|
|
75
|
+
raise typer.Exit(1) from None
|
|
76
|
+
except Exception as e:
|
|
77
|
+
out.error(f"Connection failed: {e}")
|
|
78
|
+
raise typer.Exit(1) from None
|
|
79
|
+
|
|
80
|
+
if result.get("connected"):
|
|
81
|
+
out.success(f"Connected in {result['handshake_ms']}ms")
|
|
82
|
+
sections = [
|
|
83
|
+
(
|
|
84
|
+
"Handshake",
|
|
85
|
+
[
|
|
86
|
+
("URL", result["url"]),
|
|
87
|
+
("Latency", f"{result['handshake_ms']}ms"),
|
|
88
|
+
("Subprotocol", str(result.get("subprotocol") or "none")),
|
|
89
|
+
("Remote", str(result.get("remote_address", ""))),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
out.detail(title="WebSocket Connection", sections=sections, data_for_json=result)
|
|
94
|
+
else:
|
|
95
|
+
out.error(f"Connection failed: {result.get('error')}")
|
|
96
|
+
if actx.json_mode:
|
|
97
|
+
out.raw_json(result)
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# send
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
@app.command()
|
|
105
|
+
def send(
|
|
106
|
+
ctx: typer.Context,
|
|
107
|
+
endpoint: Annotated[str, typer.Argument(help="WebSocket endpoint path.")],
|
|
108
|
+
message: Annotated[str, typer.Argument(help="Message to send (string or JSON).")],
|
|
109
|
+
token: Annotated[str | None, typer.Option("--token", help="Bearer token.")] = None,
|
|
110
|
+
timeout: Annotated[int, typer.Option("--timeout", help="Wait N seconds for response.")] = 5,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Send a message over WebSocket and show the response."""
|
|
113
|
+
actx: AppContext = ctx.obj
|
|
114
|
+
out = actx.output
|
|
115
|
+
|
|
116
|
+
ws_url = _build_ws_url(actx, endpoint)
|
|
117
|
+
out.info(f"Sending to {ws_url} ...")
|
|
118
|
+
|
|
119
|
+
async def _run() -> dict:
|
|
120
|
+
try:
|
|
121
|
+
import websockets # type: ignore[import-untyped]
|
|
122
|
+
except ImportError:
|
|
123
|
+
raise RuntimeError("websockets not installed. Run: uv add websockets") from None
|
|
124
|
+
|
|
125
|
+
headers = {}
|
|
126
|
+
if token:
|
|
127
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
128
|
+
elif actx.client.api_key:
|
|
129
|
+
headers["Authorization"] = f"Bearer {actx.client.api_key}"
|
|
130
|
+
|
|
131
|
+
async with websockets.connect(ws_url, additional_headers=headers) as ws:
|
|
132
|
+
await ws.send(message)
|
|
133
|
+
try:
|
|
134
|
+
response = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
|
135
|
+
return {"sent": message, "received": response, "success": True}
|
|
136
|
+
except TimeoutError:
|
|
137
|
+
return {"sent": message, "received": None, "success": False, "error": f"No response within {timeout}s"}
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
result = asyncio.run(_run())
|
|
141
|
+
except RuntimeError as e:
|
|
142
|
+
out.error(str(e))
|
|
143
|
+
raise typer.Exit(1) from None
|
|
144
|
+
except Exception as e:
|
|
145
|
+
out.error(f"WebSocket error: {e}")
|
|
146
|
+
raise typer.Exit(1) from None
|
|
147
|
+
|
|
148
|
+
if result.get("success"):
|
|
149
|
+
out.success("Message sent and response received.")
|
|
150
|
+
out.text(f" Sent: {result['sent']}")
|
|
151
|
+
out.text(f" Received: {result['received']}")
|
|
152
|
+
else:
|
|
153
|
+
out.warn(f"Sent message but: {result.get('error', 'no response')}")
|
|
154
|
+
|
|
155
|
+
if actx.json_mode:
|
|
156
|
+
out.raw_json(result)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# listen
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
@app.command()
|
|
163
|
+
def listen(
|
|
164
|
+
ctx: typer.Context,
|
|
165
|
+
endpoint: Annotated[str, typer.Argument(help="WebSocket endpoint path.")],
|
|
166
|
+
duration: Annotated[int, typer.Option("--duration", help="Listen for N seconds.")] = 30,
|
|
167
|
+
token: Annotated[str | None, typer.Option("--token", help="Bearer token.")] = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Listen for incoming WebSocket messages for a duration."""
|
|
170
|
+
actx: AppContext = ctx.obj
|
|
171
|
+
out = actx.output
|
|
172
|
+
|
|
173
|
+
ws_url = _build_ws_url(actx, endpoint)
|
|
174
|
+
out.info(f"Listening on {ws_url} for {duration}s (Ctrl+C to stop) ...")
|
|
175
|
+
|
|
176
|
+
async def _run() -> list[str]:
|
|
177
|
+
try:
|
|
178
|
+
import websockets # type: ignore[import-untyped]
|
|
179
|
+
except ImportError:
|
|
180
|
+
raise RuntimeError("websockets not installed. Run: uv add websockets") from None
|
|
181
|
+
|
|
182
|
+
headers = {}
|
|
183
|
+
if token:
|
|
184
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
185
|
+
elif actx.client.api_key:
|
|
186
|
+
headers["Authorization"] = f"Bearer {actx.client.api_key}"
|
|
187
|
+
|
|
188
|
+
messages: list[str] = []
|
|
189
|
+
deadline = time.monotonic() + duration
|
|
190
|
+
|
|
191
|
+
async with websockets.connect(ws_url, additional_headers=headers) as ws:
|
|
192
|
+
while time.monotonic() < deadline:
|
|
193
|
+
remaining = deadline - time.monotonic()
|
|
194
|
+
try:
|
|
195
|
+
msg = await asyncio.wait_for(ws.recv(), timeout=min(remaining, 1.0))
|
|
196
|
+
messages.append(str(msg))
|
|
197
|
+
typer.echo(f" [{len(messages)}] {msg}")
|
|
198
|
+
except TimeoutError:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
return messages
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
messages = asyncio.run(_run())
|
|
205
|
+
except RuntimeError as e:
|
|
206
|
+
out.error(str(e))
|
|
207
|
+
raise typer.Exit(1) from None
|
|
208
|
+
except KeyboardInterrupt:
|
|
209
|
+
out.info("Listening stopped.")
|
|
210
|
+
return
|
|
211
|
+
except Exception as e:
|
|
212
|
+
out.error(f"WebSocket error: {e}")
|
|
213
|
+
raise typer.Exit(1) from None
|
|
214
|
+
|
|
215
|
+
out.success(f"Received {len(messages)} messages in {duration}s.")
|
|
216
|
+
if actx.json_mode:
|
|
217
|
+
out.raw_json({"messages": messages, "count": len(messages)})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# load-test
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
@app.command(name="load-test")
|
|
224
|
+
def load_test(
|
|
225
|
+
ctx: typer.Context,
|
|
226
|
+
endpoint: Annotated[str, typer.Argument(help="WebSocket endpoint path.")],
|
|
227
|
+
connections: Annotated[int, typer.Option("--connections", help="Number of concurrent connections.")] = 10,
|
|
228
|
+
duration: Annotated[int, typer.Option("--duration", help="Test duration in seconds.")] = 10,
|
|
229
|
+
token: Annotated[str | None, typer.Option("--token", help="Bearer token.")] = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Concurrent WebSocket connection load test."""
|
|
232
|
+
actx: AppContext = ctx.obj
|
|
233
|
+
out = actx.output
|
|
234
|
+
|
|
235
|
+
ws_url = _build_ws_url(actx, endpoint)
|
|
236
|
+
out.info(f"Load testing {ws_url} — {connections} connections for {duration}s ...")
|
|
237
|
+
|
|
238
|
+
async def _single_connection(conn_id: int, results: list[dict]) -> None:
|
|
239
|
+
try:
|
|
240
|
+
import websockets # type: ignore[import-untyped]
|
|
241
|
+
except ImportError:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
headers = {}
|
|
245
|
+
if token:
|
|
246
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
247
|
+
elif actx.client.api_key:
|
|
248
|
+
headers["Authorization"] = f"Bearer {actx.client.api_key}"
|
|
249
|
+
|
|
250
|
+
start = time.monotonic()
|
|
251
|
+
messages_received = 0
|
|
252
|
+
error: str | None = None
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
async with websockets.connect(ws_url, additional_headers=headers) as ws:
|
|
256
|
+
connected_ms = round((time.monotonic() - start) * 1000)
|
|
257
|
+
deadline = start + duration
|
|
258
|
+
while time.monotonic() < deadline:
|
|
259
|
+
remaining = deadline - time.monotonic()
|
|
260
|
+
try:
|
|
261
|
+
await asyncio.wait_for(ws.recv(), timeout=min(remaining, 0.5))
|
|
262
|
+
messages_received += 1
|
|
263
|
+
except TimeoutError:
|
|
264
|
+
continue
|
|
265
|
+
except Exception as e:
|
|
266
|
+
error = str(e)
|
|
267
|
+
connected_ms = round((time.monotonic() - start) * 1000)
|
|
268
|
+
|
|
269
|
+
results.append(
|
|
270
|
+
{
|
|
271
|
+
"connection": conn_id,
|
|
272
|
+
"connected": error is None,
|
|
273
|
+
"connect_ms": connected_ms,
|
|
274
|
+
"messages_received": messages_received,
|
|
275
|
+
"error": error,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def _run() -> list[dict]:
|
|
280
|
+
try:
|
|
281
|
+
import websockets # type: ignore[import-untyped]
|
|
282
|
+
|
|
283
|
+
_ = websockets
|
|
284
|
+
except ImportError:
|
|
285
|
+
raise RuntimeError("websockets not installed. Run: uv add websockets") from None
|
|
286
|
+
|
|
287
|
+
results: list[dict] = []
|
|
288
|
+
tasks = [_single_connection(i + 1, results) for i in range(connections)]
|
|
289
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
290
|
+
return results
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
results = asyncio.run(_run())
|
|
294
|
+
except RuntimeError as e:
|
|
295
|
+
out.error(str(e))
|
|
296
|
+
raise typer.Exit(1) from None
|
|
297
|
+
except Exception as e:
|
|
298
|
+
out.error(f"Load test failed: {e}")
|
|
299
|
+
raise typer.Exit(1) from None
|
|
300
|
+
|
|
301
|
+
successful = sum(1 for r in results if r.get("connected"))
|
|
302
|
+
failed = len(results) - successful
|
|
303
|
+
avg_connect_ms = round(sum(r.get("connect_ms", 0) for r in results) / max(len(results), 1))
|
|
304
|
+
total_messages = sum(r.get("messages_received", 0) for r in results)
|
|
305
|
+
|
|
306
|
+
out.text("")
|
|
307
|
+
out.text(f" Connections: {connections}")
|
|
308
|
+
out.text(f" Successful: [green]{successful}[/green]")
|
|
309
|
+
out.text(f" Failed: [red]{failed}[/red]")
|
|
310
|
+
out.text(f" Avg connect: {avg_connect_ms}ms")
|
|
311
|
+
out.text(f" Total messages: {total_messages}")
|
|
312
|
+
|
|
313
|
+
if actx.json_mode:
|
|
314
|
+
out.raw_json(
|
|
315
|
+
{
|
|
316
|
+
"connections": connections,
|
|
317
|
+
"successful": successful,
|
|
318
|
+
"failed": failed,
|
|
319
|
+
"avg_connect_ms": avg_connect_ms,
|
|
320
|
+
"total_messages": total_messages,
|
|
321
|
+
"results": results,
|
|
322
|
+
}
|
|
323
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core infrastructure for kctl-api."""
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Async REST API client using httpx.
|
|
2
|
+
|
|
3
|
+
Mirror of :class:`kctl_api.core.client.ApiClient` with ``async`` methods
|
|
4
|
+
and :class:`httpx.AsyncClient` under the hood.
|
|
5
|
+
|
|
6
|
+
Subclasses :class:`kctl_lib.async_api_client.AsyncAPIClient` and keeps
|
|
7
|
+
dual-auth (JWT + API key) as a service-specific override.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from kctl_lib.async_api_client import AsyncAPIClient
|
|
17
|
+
from kctl_lib.exceptions import AuthenticationError
|
|
18
|
+
from kctl_lib.exceptions import ConnectionError as KctlConnectionError
|
|
19
|
+
|
|
20
|
+
from kctl_api.core.exceptions import APIError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncApiClient(AsyncAPIClient):
|
|
24
|
+
"""Asynchronous httpx client for Kodemeio REST APIs.
|
|
25
|
+
|
|
26
|
+
Extends the kctl-lib async base with dual-auth support (JWT bearer
|
|
27
|
+
token **or** API key via ``X-API-Key`` header) and service-specific helpers.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
AUTH_HEADER: str = "Authorization"
|
|
31
|
+
AUTH_PREFIX: str = "Bearer"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
api_key: str = "",
|
|
37
|
+
jwt_token: str = "",
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
):
|
|
40
|
+
# Resolve the primary credential for the base class.
|
|
41
|
+
credential = jwt_token or api_key or "none"
|
|
42
|
+
|
|
43
|
+
# Store extras *before* super().__init__ since _build_auth_header
|
|
44
|
+
# is called during construction.
|
|
45
|
+
self._api_key = api_key
|
|
46
|
+
self._jwt_token = jwt_token
|
|
47
|
+
|
|
48
|
+
if not base_url:
|
|
49
|
+
raise KctlConnectionError(
|
|
50
|
+
"(not configured)",
|
|
51
|
+
ValueError("No API URL configured. Run: kctl-api config init"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
super().__init__(
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
credential=credential,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Override with follow_redirects (base class doesn't set it)
|
|
61
|
+
self._client = httpx.AsyncClient(
|
|
62
|
+
base_url=self._base_url,
|
|
63
|
+
headers=self._build_auth_header(),
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
follow_redirects=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Auth override — dual-auth (JWT or API key)
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _build_auth_header(self) -> dict[str, str]:
|
|
73
|
+
"""Build authentication headers supporting JWT and API key."""
|
|
74
|
+
if self._jwt_token:
|
|
75
|
+
return {"Authorization": f"Bearer {self._jwt_token}"}
|
|
76
|
+
if self._api_key:
|
|
77
|
+
return {"X-API-Key": self._api_key}
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Error mapping override
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
async def _request(self, method: str, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
85
|
+
"""Override to use kctl-api's APIError (wraps httpx.Response)."""
|
|
86
|
+
try:
|
|
87
|
+
return await super()._request(method, endpoint, **kwargs)
|
|
88
|
+
except KctlConnectionError:
|
|
89
|
+
raise
|
|
90
|
+
except AuthenticationError:
|
|
91
|
+
raise
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
from kctl_lib.exceptions import APIError as BaseAPIError
|
|
94
|
+
|
|
95
|
+
if isinstance(exc, BaseAPIError):
|
|
96
|
+
raise APIError(detail=exc.detail) from exc
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Service-specific methods
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async def health_check(self) -> tuple[bool, dict]:
|
|
104
|
+
"""Check API health."""
|
|
105
|
+
try:
|
|
106
|
+
data = await self.get("/api/v1/health")
|
|
107
|
+
return data.get("status") == "ok", data
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return False, {"status": "error", "error": str(e)}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def async_batch(*coroutines: Any, max_concurrent: int = 10) -> list[Any]:
|
|
113
|
+
"""Run multiple async operations with a concurrency limit."""
|
|
114
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
115
|
+
|
|
116
|
+
async def bounded(coro: Any) -> Any:
|
|
117
|
+
async with semaphore:
|
|
118
|
+
return await coro
|
|
119
|
+
|
|
120
|
+
return list(await asyncio.gather(*(bounded(c) for c in coroutines)))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Typer global callback and shared context for kctl-api."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from kctl_lib.callbacks import AppContextBase
|
|
9
|
+
|
|
10
|
+
from kctl_api.core.config import resolve_connection
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from kctl_api.core.client import ApiClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AppContext(AppContextBase):
|
|
18
|
+
"""API-specific application context passed through Typer's ctx.obj."""
|
|
19
|
+
|
|
20
|
+
url_override: str | None = None
|
|
21
|
+
ai_url_override: str | None = None
|
|
22
|
+
api_key_override: str | None = None
|
|
23
|
+
database_url_override: str | None = None
|
|
24
|
+
redis_url_override: str | None = None
|
|
25
|
+
_client: ApiClient | None = field(default=None, repr=False)
|
|
26
|
+
_ai_client: ApiClient | None = field(default=None, repr=False)
|
|
27
|
+
_db_engine: Any = field(default=None, repr=False)
|
|
28
|
+
_redis_client: Any = field(default=None, repr=False)
|
|
29
|
+
|
|
30
|
+
def _resolve(self) -> tuple[str, str, str, str, str]:
|
|
31
|
+
"""Resolve connection params once."""
|
|
32
|
+
return resolve_connection(
|
|
33
|
+
profile_name=self.profile,
|
|
34
|
+
url_override=self.url_override,
|
|
35
|
+
ai_url_override=self.ai_url_override,
|
|
36
|
+
api_key_override=self.api_key_override,
|
|
37
|
+
database_url_override=self.database_url_override,
|
|
38
|
+
redis_url_override=self.redis_url_override,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def client(self) -> ApiClient:
|
|
43
|
+
"""Lazy API client for api-main."""
|
|
44
|
+
if self._client is None:
|
|
45
|
+
from kctl_api.core.client import ApiClient
|
|
46
|
+
|
|
47
|
+
url, _ai_url, api_key, _db_url, _redis_url = self._resolve()
|
|
48
|
+
self._client = ApiClient(
|
|
49
|
+
base_url=url,
|
|
50
|
+
api_key=api_key,
|
|
51
|
+
)
|
|
52
|
+
return self._client
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ai_client(self) -> ApiClient:
|
|
56
|
+
"""Lazy API client for ai-main."""
|
|
57
|
+
if self._ai_client is None:
|
|
58
|
+
from kctl_api.core.client import ApiClient
|
|
59
|
+
|
|
60
|
+
_url, ai_url, api_key, _db_url, _redis_url = self._resolve()
|
|
61
|
+
if not ai_url:
|
|
62
|
+
from kctl_api.core.exceptions import ConfigError
|
|
63
|
+
|
|
64
|
+
raise ConfigError("No ai_url configured. Set it with: kctl-api config set ai_url <url>")
|
|
65
|
+
self._ai_client = ApiClient(
|
|
66
|
+
base_url=ai_url,
|
|
67
|
+
api_key=api_key,
|
|
68
|
+
)
|
|
69
|
+
return self._ai_client
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def database_url(self) -> str:
|
|
73
|
+
"""Resolved database URL."""
|
|
74
|
+
_url, _ai_url, _api_key, db_url, _redis_url = self._resolve()
|
|
75
|
+
return db_url
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def redis_url(self) -> str:
|
|
79
|
+
"""Resolved Redis URL."""
|
|
80
|
+
_url, _ai_url, _api_key, _db_url, redis_url = self._resolve()
|
|
81
|
+
return redis_url
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
"""Close the underlying HTTP clients if they were initialised."""
|
|
85
|
+
if self._client is not None:
|
|
86
|
+
self._client.close()
|
|
87
|
+
if self._ai_client is not None:
|
|
88
|
+
self._ai_client.close()
|