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,223 @@
1
+ """Rate limit management commands for kctl-api.
2
+
3
+ Inspect tier config, test rate limits, and check Redis state.
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="rate-limit", help="Rate limit — tiers, test, status, simulate.", no_args_is_help=True)
17
+
18
+ # Default tier config matches kctl-lib RateTier + DEFAULT_TIERS
19
+ _DEFAULT_TIERS: dict[str, int] = {
20
+ "free": 30,
21
+ "user": 100,
22
+ "premium": 300,
23
+ "admin": 600,
24
+ }
25
+ _WINDOW_SECONDS = 60
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # tiers
30
+ # ---------------------------------------------------------------------------
31
+ @app.command()
32
+ def tiers(ctx: typer.Context) -> None:
33
+ """Show tier rate limit configuration (requests per minute)."""
34
+ actx: AppContext = ctx.obj
35
+ out = actx.output
36
+
37
+ rows = [
38
+ [tier, str(rpm), str(round(rpm / _WINDOW_SECONDS, 2)), f"~{round(_WINDOW_SECONDS / rpm * 1000)}ms min interval"]
39
+ for tier, rpm in _DEFAULT_TIERS.items()
40
+ ]
41
+
42
+ out.table(
43
+ title="Rate Limit Tiers (requests per 60s window)",
44
+ columns=[
45
+ ("Tier", "bold"),
46
+ ("Req/min", ""),
47
+ ("Req/sec", ""),
48
+ ("Note", ""),
49
+ ],
50
+ rows=rows,
51
+ data_for_json=[{"tier": t, "rpm": r, "window_seconds": _WINDOW_SECONDS} for t, r in _DEFAULT_TIERS.items()],
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # test
57
+ # ---------------------------------------------------------------------------
58
+ @app.command()
59
+ def test(
60
+ ctx: typer.Context,
61
+ endpoint: Annotated[str, typer.Argument(help="Endpoint path to test (e.g. /api/v1/health).")],
62
+ tier: Annotated[str, typer.Option("--tier", help="Tier to simulate.")] = "user",
63
+ count: Annotated[int, typer.Option("--count", help="Number of requests to send.")] = 50,
64
+ ) -> None:
65
+ """Send sequential requests to test rate limit behavior."""
66
+ actx: AppContext = ctx.obj
67
+ out = actx.output
68
+
69
+ limit = _DEFAULT_TIERS.get(tier, 100)
70
+ out.info(f"Testing rate limit — {count} requests to {endpoint} (tier={tier}, limit={limit}/min)")
71
+
72
+ results: list[dict] = []
73
+ rate_limited_at: int | None = None
74
+
75
+ for i in range(1, count + 1):
76
+ try:
77
+ start = time.monotonic()
78
+ response = actx.client.get_raw(endpoint)
79
+ elapsed_ms = round((time.monotonic() - start) * 1000)
80
+ status = response.status_code
81
+
82
+ results.append({"request": i, "status": status, "latency_ms": elapsed_ms})
83
+
84
+ if status == 429 and rate_limited_at is None:
85
+ rate_limited_at = i
86
+ out.warn(f"Rate limited at request #{i} (HTTP 429)")
87
+ break
88
+ elif status >= 500:
89
+ out.warn(f"Request #{i} returned {status}")
90
+
91
+ except Exception as e:
92
+ results.append({"request": i, "status": "error", "error": str(e)})
93
+ out.warn(f"Request #{i} failed: {e}")
94
+
95
+ success_count = sum(1 for r in results if isinstance(r.get("status"), int) and r["status"] < 400)
96
+ limited_count = sum(1 for r in results if r.get("status") == 429)
97
+ avg_latency = round(sum(r.get("latency_ms", 0) for r in results if "latency_ms" in r) / max(len(results), 1))
98
+
99
+ out.text("")
100
+ out.text(f" Sent: {len(results)}")
101
+ out.text(f" Success (2xx): [green]{success_count}[/green]")
102
+ out.text(f" Rate limited: [yellow]{limited_count}[/yellow]")
103
+ out.text(f" Avg latency: {avg_latency}ms")
104
+ if rate_limited_at:
105
+ out.text(f" Limited at: request #{rate_limited_at}")
106
+
107
+ if actx.json_mode:
108
+ out.raw_json(
109
+ {
110
+ "endpoint": endpoint,
111
+ "tier": tier,
112
+ "sent": len(results),
113
+ "success": success_count,
114
+ "rate_limited": limited_count,
115
+ "rate_limited_at": rate_limited_at,
116
+ "avg_latency_ms": avg_latency,
117
+ "results": results,
118
+ }
119
+ )
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # status
124
+ # ---------------------------------------------------------------------------
125
+ @app.command()
126
+ def status(ctx: typer.Context) -> None:
127
+ """Inspect Redis rate limit keys (rl: prefix)."""
128
+ actx: AppContext = ctx.obj
129
+ out = actx.output
130
+
131
+ redis_url = actx.redis_url
132
+ if not redis_url:
133
+ out.error("No redis_url configured.")
134
+ raise typer.Exit(1)
135
+
136
+ async def _run() -> list[dict]:
137
+ from kctl_api.core.redis import close_redis, get_redis
138
+
139
+ try:
140
+ client = get_redis(redis_url)
141
+ keys: list[str] = []
142
+ async for key in client.scan_iter(match="rl:*", count=200):
143
+ keys.append(key)
144
+
145
+ if not keys:
146
+ return []
147
+
148
+ results = []
149
+ for key in keys[:100]: # cap at 100 for display
150
+ try:
151
+ val = await client.get(key)
152
+ ttl = await client.ttl(key)
153
+ results.append(
154
+ {
155
+ "key": key,
156
+ "count": int(val) if val else 0,
157
+ "ttl_seconds": ttl,
158
+ }
159
+ )
160
+ except Exception:
161
+ pass
162
+ return sorted(results, key=lambda x: -x.get("count", 0))
163
+ finally:
164
+ await close_redis()
165
+
166
+ try:
167
+ entries = asyncio.run(_run())
168
+ except Exception as e:
169
+ out.error(f"Redis error: {e}")
170
+ raise typer.Exit(1) from None
171
+
172
+ if not entries:
173
+ out.info("No active rate limit keys found (prefix: rl:*).")
174
+ return
175
+
176
+ rows = [[e["key"], str(e["count"]), str(e["ttl_seconds"])] for e in entries]
177
+ out.table(
178
+ title=f"Active Rate Limit Keys ({len(entries)})",
179
+ columns=[("Key", ""), ("Requests", "bold"), ("TTL (s)", "")],
180
+ rows=rows,
181
+ data_for_json=entries,
182
+ )
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # simulate
187
+ # ---------------------------------------------------------------------------
188
+ @app.command()
189
+ def simulate(
190
+ ctx: typer.Context,
191
+ tier: Annotated[str, typer.Argument(help="Tier to simulate: free, user, premium, admin.")],
192
+ ) -> None:
193
+ """Show expected rate limit behavior for a tier."""
194
+ actx: AppContext = ctx.obj
195
+ out = actx.output
196
+
197
+ if tier not in _DEFAULT_TIERS:
198
+ out.error(f"Unknown tier '{tier}'. Choose from: {', '.join(_DEFAULT_TIERS)}")
199
+ raise typer.Exit(1)
200
+
201
+ limit = _DEFAULT_TIERS[tier]
202
+ interval_ms = round(_WINDOW_SECONDS / limit * 1000)
203
+ burst_warning = limit
204
+
205
+ sections = [
206
+ (
207
+ f"Tier: {tier}",
208
+ [
209
+ ("Max requests", f"{limit} per {_WINDOW_SECONDS}s window"),
210
+ ("Min interval", f"{interval_ms}ms between requests"),
211
+ ("Burst (window)", f"Up to {burst_warning} requests before 429"),
212
+ ("Response on limit", "HTTP 429 Too Many Requests"),
213
+ ("Reset", f"Counter resets after {_WINDOW_SECONDS}s TTL"),
214
+ ("Header", "X-RateLimit-Remaining, X-RateLimit-Reset (if configured)"),
215
+ ],
216
+ )
217
+ ]
218
+
219
+ out.detail(
220
+ title=f"Rate Limit Simulation: {tier}",
221
+ sections=sections,
222
+ data_for_json={"tier": tier, "limit": limit, "window": _WINDOW_SECONDS, "interval_ms": interval_ms},
223
+ )
@@ -0,0 +1,100 @@
1
+ """Real-time / SSE commands for kctl-api.
2
+
3
+ Presence, heartbeat, and event subscription management.
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="realtime", help="Real-time features — presence, heartbeat, subscribe.", no_args_is_help=True)
17
+
18
+ _BASE = "/api/v1/realtime"
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # presence
23
+ # ---------------------------------------------------------------------------
24
+ @app.command()
25
+ def presence(
26
+ ctx: typer.Context,
27
+ scope: Annotated[str, typer.Argument(help="Presence scope (e.g. 'global', a channel name).")] = "global",
28
+ ) -> None:
29
+ """Get online users for a scope via GET /api/v1/realtime/presence/{scope}."""
30
+ actx: AppContext = ctx.obj
31
+ out = actx.output
32
+
33
+ try:
34
+ data = actx.client.get(f"{_BASE}/presence/{scope}")
35
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
36
+ out.error(str(e))
37
+ raise typer.Exit(1) from None
38
+
39
+ if not data:
40
+ out.info("No presence data available.")
41
+ return
42
+
43
+ out.detail(
44
+ title=f"Presence: {scope}",
45
+ sections=[
46
+ ("Status", [(k, str(v)) for k, v in data.items()]),
47
+ ],
48
+ data_for_json=data,
49
+ )
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # heartbeat
54
+ # ---------------------------------------------------------------------------
55
+ @app.command()
56
+ def heartbeat(
57
+ ctx: typer.Context,
58
+ scope: Annotated[str, typer.Argument(help="Presence scope.")] = "global",
59
+ ) -> None:
60
+ """Send a heartbeat ping via POST /api/v1/realtime/presence/{scope}/heartbeat."""
61
+ actx: AppContext = ctx.obj
62
+ out = actx.output
63
+
64
+ try:
65
+ result = actx.client.post(f"{_BASE}/presence/{scope}/heartbeat")
66
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
67
+ out.error(str(e))
68
+ raise typer.Exit(1) from None
69
+
70
+ out.success(f"Heartbeat sent for scope: {scope}")
71
+ if actx.json_mode:
72
+ out.raw_json(result)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # subscribe
77
+ # ---------------------------------------------------------------------------
78
+ @app.command()
79
+ def subscribe(
80
+ ctx: typer.Context,
81
+ channel: Annotated[str, typer.Argument(help="Channel name to subscribe to.")],
82
+ ) -> None:
83
+ """Subscribe to a real-time SSE channel via GET /api/v1/realtime/sse/{channel}.
84
+
85
+ Note: This opens a streaming connection. Press Ctrl+C to stop.
86
+ """
87
+ actx: AppContext = ctx.obj
88
+ out = actx.output
89
+
90
+ out.info(f"Subscribing to channel: {channel} (Ctrl+C to stop)")
91
+ try:
92
+ with actx.client.stream_sse(f"{_BASE}/sse/{channel}") as response:
93
+ for line in response.iter_lines():
94
+ if line.strip():
95
+ out.text(line)
96
+ except KeyboardInterrupt:
97
+ out.info("Subscription stopped.")
98
+ except (AuthenticationError, KctlConnectionError, APIError) as e:
99
+ out.error(str(e))
100
+ raise typer.Exit(1) from None