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,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
+ )
@@ -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()