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,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
|