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,609 @@
1
+ """Redis management commands for kctl-api.
2
+
3
+ Inspect keys, get/delete values, and view server info.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from kctl_api.core.callbacks import AppContext
14
+
15
+ app = typer.Typer(name="redis", help="Redis management — info, keys, get, delete, stats.", no_args_is_help=True)
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # info
20
+ # ---------------------------------------------------------------------------
21
+ @app.command()
22
+ def info(ctx: typer.Context) -> None:
23
+ """Show Redis server info via async PING + INFO."""
24
+ actx: AppContext = ctx.obj
25
+ out = actx.output
26
+
27
+ redis_url = actx.redis_url
28
+ if not redis_url:
29
+ out.error("No redis_url configured.")
30
+ raise typer.Exit(1)
31
+
32
+ async def _run() -> dict:
33
+ from kctl_api.core.redis import close_redis, get_redis
34
+
35
+ try:
36
+ client = get_redis(redis_url)
37
+ info_data = await client.info()
38
+ return dict(info_data) if info_data else {}
39
+ finally:
40
+ await close_redis()
41
+
42
+ try:
43
+ data = asyncio.run(_run())
44
+ except ImportError as e:
45
+ out.error(str(e))
46
+ raise typer.Exit(1) from None
47
+ except Exception as e:
48
+ out.error(f"Redis error: {e}")
49
+ raise typer.Exit(1) from None
50
+
51
+ # Show key metrics
52
+ sections = [
53
+ (
54
+ "Server",
55
+ [
56
+ ("Version", str(data.get("redis_version", ""))),
57
+ ("Uptime (seconds)", str(data.get("uptime_in_seconds", ""))),
58
+ ("Connected Clients", str(data.get("connected_clients", ""))),
59
+ ("Used Memory", str(data.get("used_memory_human", ""))),
60
+ (
61
+ "Total Keys",
62
+ str(
63
+ sum(
64
+ data.get(f"db{i}", {}).get("keys", 0)
65
+ for i in range(16)
66
+ if isinstance(data.get(f"db{i}"), dict)
67
+ )
68
+ ),
69
+ ),
70
+ ],
71
+ ),
72
+ ]
73
+
74
+ out.detail(title="Redis Info", sections=sections, data_for_json=data)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # keys
79
+ # ---------------------------------------------------------------------------
80
+ @app.command()
81
+ def keys(
82
+ ctx: typer.Context,
83
+ pattern: Annotated[str, typer.Argument(help="Key pattern (e.g. 'myapp:*').")] = "*",
84
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max keys to return.")] = 50,
85
+ ) -> None:
86
+ """List Redis keys matching a pattern via async SCAN."""
87
+ actx: AppContext = ctx.obj
88
+ out = actx.output
89
+
90
+ redis_url = actx.redis_url
91
+ if not redis_url:
92
+ out.error("No redis_url configured.")
93
+ raise typer.Exit(1)
94
+
95
+ async def _run() -> list[str]:
96
+ from kctl_api.core.redis import close_redis, get_redis
97
+
98
+ try:
99
+ client = get_redis(redis_url)
100
+ result: list[str] = []
101
+ async for key in client.scan_iter(match=pattern, count=100):
102
+ result.append(key)
103
+ if len(result) >= limit:
104
+ break
105
+ return result
106
+ finally:
107
+ await close_redis()
108
+
109
+ try:
110
+ found_keys = asyncio.run(_run())
111
+ except ImportError as e:
112
+ out.error(str(e))
113
+ raise typer.Exit(1) from None
114
+ except Exception as e:
115
+ out.error(f"Redis error: {e}")
116
+ raise typer.Exit(1) from None
117
+
118
+ rows = [[k] for k in found_keys]
119
+
120
+ out.table(
121
+ title=f"Redis Keys ({len(found_keys)} matching '{pattern}')",
122
+ columns=[("Key", "")],
123
+ rows=rows,
124
+ data_for_json=[{"key": k} for k in found_keys],
125
+ )
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # get
130
+ # ---------------------------------------------------------------------------
131
+ @app.command(name="get")
132
+ def get_key(
133
+ ctx: typer.Context,
134
+ key: Annotated[str, typer.Argument(help="Redis key to retrieve.")],
135
+ ) -> None:
136
+ """Get a Redis key value."""
137
+ actx: AppContext = ctx.obj
138
+ out = actx.output
139
+
140
+ redis_url = actx.redis_url
141
+ if not redis_url:
142
+ out.error("No redis_url configured.")
143
+ raise typer.Exit(1)
144
+
145
+ async def _run() -> str | None:
146
+ from kctl_api.core.redis import close_redis, get_redis
147
+
148
+ try:
149
+ client = get_redis(redis_url)
150
+ return await client.get(key)
151
+ finally:
152
+ await close_redis()
153
+
154
+ try:
155
+ value = asyncio.run(_run())
156
+ except Exception as e:
157
+ out.error(f"Redis error: {e}")
158
+ raise typer.Exit(1) from None
159
+
160
+ if value is None:
161
+ out.info(f"Key not found: {key}")
162
+ return
163
+
164
+ if actx.json_mode:
165
+ out.raw_json({"key": key, "value": value})
166
+ else:
167
+ out.text(f"{key} = {value}")
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # delete
172
+ # ---------------------------------------------------------------------------
173
+ @app.command()
174
+ def delete(
175
+ ctx: typer.Context,
176
+ key: Annotated[str, typer.Argument(help="Redis key to delete.")],
177
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
178
+ ) -> None:
179
+ """Delete a Redis key."""
180
+ actx: AppContext = ctx.obj
181
+ out = actx.output
182
+
183
+ redis_url = actx.redis_url
184
+ if not redis_url:
185
+ out.error("No redis_url configured.")
186
+ raise typer.Exit(1)
187
+
188
+ if not force:
189
+ confirm = typer.confirm(f"Delete Redis key '{key}'?", default=False)
190
+ if not confirm:
191
+ out.info("Cancelled.")
192
+ raise typer.Exit(0)
193
+
194
+ async def _run() -> int:
195
+ from kctl_api.core.redis import close_redis, get_redis
196
+
197
+ try:
198
+ client = get_redis(redis_url)
199
+ return await client.delete(key)
200
+ finally:
201
+ await close_redis()
202
+
203
+ try:
204
+ deleted = asyncio.run(_run())
205
+ except Exception as e:
206
+ out.error(f"Redis error: {e}")
207
+ raise typer.Exit(1) from None
208
+
209
+ if deleted:
210
+ out.success(f"Key '{key}' deleted.")
211
+ else:
212
+ out.info(f"Key '{key}' did not exist.")
213
+
214
+ if actx.json_mode:
215
+ out.raw_json({"key": key, "deleted": deleted})
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # flush
220
+ # ---------------------------------------------------------------------------
221
+ @app.command()
222
+ def flush(
223
+ ctx: typer.Context,
224
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation.")] = False,
225
+ ) -> None:
226
+ """Flush the current Redis database (FLUSHDB)."""
227
+ actx: AppContext = ctx.obj
228
+ out = actx.output
229
+
230
+ redis_url = actx.redis_url
231
+ if not redis_url:
232
+ out.error("No redis_url configured.")
233
+ raise typer.Exit(1)
234
+
235
+ if not force:
236
+ confirm = typer.confirm("Flush the entire Redis database?", default=False)
237
+ if not confirm:
238
+ out.info("Cancelled.")
239
+ raise typer.Exit(0)
240
+
241
+ async def _run() -> bool:
242
+ from kctl_api.core.redis import close_redis, get_redis
243
+
244
+ try:
245
+ client = get_redis(redis_url)
246
+ return await client.flushdb()
247
+ finally:
248
+ await close_redis()
249
+
250
+ try:
251
+ asyncio.run(_run())
252
+ except Exception as e:
253
+ out.error(f"Redis error: {e}")
254
+ raise typer.Exit(1) from None
255
+
256
+ out.success("Redis database flushed.")
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # stats
261
+ # ---------------------------------------------------------------------------
262
+ @app.command()
263
+ def stats(ctx: typer.Context) -> None:
264
+ """Show Redis memory and key statistics."""
265
+ actx: AppContext = ctx.obj
266
+ out = actx.output
267
+
268
+ redis_url = actx.redis_url
269
+ if not redis_url:
270
+ out.error("No redis_url configured.")
271
+ raise typer.Exit(1)
272
+
273
+ async def _run() -> dict:
274
+ from kctl_api.core.redis import close_redis, get_redis
275
+
276
+ try:
277
+ client = get_redis(redis_url)
278
+ info_data = await client.info("memory")
279
+ dbsize = await client.dbsize()
280
+ result = dict(info_data) if info_data else {}
281
+ result["dbsize"] = dbsize
282
+ return result
283
+ finally:
284
+ await close_redis()
285
+
286
+ try:
287
+ data = asyncio.run(_run())
288
+ except Exception as e:
289
+ out.error(f"Redis error: {e}")
290
+ raise typer.Exit(1) from None
291
+
292
+ out.detail(
293
+ title="Redis Stats",
294
+ sections=[
295
+ (
296
+ "Memory",
297
+ [
298
+ ("Used Memory", str(data.get("used_memory_human", ""))),
299
+ ("Peak Memory", str(data.get("used_memory_peak_human", ""))),
300
+ ("Total Keys", str(data.get("dbsize", ""))),
301
+ ],
302
+ ),
303
+ ],
304
+ data_for_json=data,
305
+ )
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # monitor
310
+ # ---------------------------------------------------------------------------
311
+ @app.command()
312
+ def monitor(
313
+ ctx: typer.Context,
314
+ duration: Annotated[int, typer.Option("--duration", help="Monitor duration in seconds.")] = 10,
315
+ ) -> None:
316
+ """Real-time Redis MONITOR — show commands as they execute."""
317
+ actx: AppContext = ctx.obj
318
+ out = actx.output
319
+
320
+ redis_url = actx.redis_url
321
+ if not redis_url:
322
+ out.error("No redis_url configured.")
323
+ raise typer.Exit(1)
324
+
325
+ out.info(f"Redis MONITOR — capturing commands for {duration}s (Ctrl+C to stop) ...")
326
+ out.warn("MONITOR can impact Redis performance. Use in dev/staging only.")
327
+
328
+ async def _run() -> list[str]:
329
+ import time
330
+
331
+ from kctl_api.core.redis import close_redis, get_redis
332
+
333
+ try:
334
+ client = get_redis(redis_url)
335
+ commands: list[str] = []
336
+ deadline = time.monotonic() + duration
337
+
338
+ # Use raw pubsub monitor via execute_command
339
+ # Note: redis-py doesn't have a native monitor context; use raw approach
340
+ monitor_client = client.monitor() # type: ignore[attr-defined]
341
+ async with monitor_client as m:
342
+ while time.monotonic() < deadline:
343
+ try:
344
+ command = await asyncio.wait_for(m.next_command(), timeout=1.0)
345
+ line = f"[{command.get('time', '')}] {command.get('command', '')}"
346
+ commands.append(line)
347
+ typer.echo(f" {line}")
348
+ except TimeoutError:
349
+ continue
350
+ except Exception:
351
+ break
352
+ return commands
353
+ finally:
354
+ await close_redis()
355
+
356
+ try:
357
+ commands = asyncio.run(_run())
358
+ out.success(f"Captured {len(commands)} commands.")
359
+ except AttributeError:
360
+ out.warn("MONITOR not available via this client version.")
361
+ out.info("Use: redis-cli MONITOR (direct Redis CLI)")
362
+ except Exception as e:
363
+ out.error(f"Redis error: {e}")
364
+ raise typer.Exit(1) from None
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # memory
369
+ # ---------------------------------------------------------------------------
370
+ @app.command()
371
+ def memory(ctx: typer.Context) -> None:
372
+ """Show memory usage per key prefix."""
373
+ actx: AppContext = ctx.obj
374
+ out = actx.output
375
+
376
+ redis_url = actx.redis_url
377
+ if not redis_url:
378
+ out.error("No redis_url configured.")
379
+ raise typer.Exit(1)
380
+
381
+ async def _run() -> dict[str, dict]:
382
+ from kctl_api.core.redis import close_redis, get_redis
383
+
384
+ try:
385
+ client = get_redis(redis_url)
386
+ prefix_stats: dict[str, dict] = {}
387
+ count = 0
388
+
389
+ async for key in client.scan_iter(match="*", count=500):
390
+ count += 1
391
+ if count > 5000:
392
+ break
393
+ # Extract prefix (up to first : or first 20 chars)
394
+ key_str = str(key)
395
+ prefix = key_str.split(":")[0] if ":" in key_str else key_str[:20]
396
+
397
+ try:
398
+ mem = await client.memory_usage(key) # type: ignore[attr-defined]
399
+ if mem:
400
+ if prefix not in prefix_stats:
401
+ prefix_stats[prefix] = {"count": 0, "bytes": 0}
402
+ prefix_stats[prefix]["count"] += 1
403
+ prefix_stats[prefix]["bytes"] += mem
404
+ except Exception:
405
+ if prefix not in prefix_stats:
406
+ prefix_stats[prefix] = {"count": 0, "bytes": 0}
407
+ prefix_stats[prefix]["count"] += 1
408
+
409
+ return prefix_stats
410
+ finally:
411
+ await close_redis()
412
+
413
+ try:
414
+ stats = asyncio.run(_run())
415
+ except Exception as e:
416
+ out.error(f"Redis error: {e}")
417
+ raise typer.Exit(1) from None
418
+
419
+ if not stats:
420
+ out.info("No keys found.")
421
+ return
422
+
423
+ sorted_stats = sorted(stats.items(), key=lambda x: -x[1].get("bytes", 0))
424
+
425
+ rows = [
426
+ [
427
+ prefix,
428
+ str(data["count"]),
429
+ f"{round(data.get('bytes', 0) / 1024, 1)} KB" if data.get("bytes") else "N/A",
430
+ ]
431
+ for prefix, data in sorted_stats
432
+ ]
433
+ out.table(
434
+ title="Redis Memory by Prefix",
435
+ columns=[("Prefix", "bold"), ("Keys", ""), ("Memory", "")],
436
+ rows=rows,
437
+ data_for_json=[{"prefix": p, **d} for p, d in sorted_stats],
438
+ )
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # pubsub
443
+ # ---------------------------------------------------------------------------
444
+ @app.command()
445
+ def pubsub(ctx: typer.Context) -> None:
446
+ """Show active Pub/Sub channels and subscriber counts."""
447
+ actx: AppContext = ctx.obj
448
+ out = actx.output
449
+
450
+ redis_url = actx.redis_url
451
+ if not redis_url:
452
+ out.error("No redis_url configured.")
453
+ raise typer.Exit(1)
454
+
455
+ async def _run() -> dict:
456
+ from kctl_api.core.redis import close_redis, get_redis
457
+
458
+ try:
459
+ client = get_redis(redis_url)
460
+ channels = await client.pubsub_channels()
461
+ numsub = await client.pubsub_numsub(*channels) if channels else {}
462
+ numpat = await client.pubsub_numpat()
463
+ return {
464
+ "channels": [str(c) for c in channels],
465
+ "subscribers": {str(k): v for k, v in numsub.items()} if isinstance(numsub, dict) else {},
466
+ "pattern_subscriptions": numpat,
467
+ }
468
+ finally:
469
+ await close_redis()
470
+
471
+ try:
472
+ data = asyncio.run(_run())
473
+ except Exception as e:
474
+ out.error(f"Redis error: {e}")
475
+ raise typer.Exit(1) from None
476
+
477
+ channels = data.get("channels", [])
478
+ subscribers = data.get("subscribers", {})
479
+
480
+ if not channels:
481
+ out.info("No active Pub/Sub channels.")
482
+ out.text(f" Pattern subscriptions: {data.get('pattern_subscriptions', 0)}")
483
+ return
484
+
485
+ rows = [[ch, str(subscribers.get(ch, 0))] for ch in channels]
486
+ out.table(
487
+ title=f"Active Pub/Sub Channels ({len(channels)})",
488
+ columns=[("Channel", "bold"), ("Subscribers", "")],
489
+ rows=rows,
490
+ data_for_json=data,
491
+ )
492
+ out.info(f"Pattern subscriptions: {data.get('pattern_subscriptions', 0)}")
493
+
494
+
495
+ # ---------------------------------------------------------------------------
496
+ # expire-audit
497
+ # ---------------------------------------------------------------------------
498
+ @app.command(name="expire-audit")
499
+ def expire_audit(
500
+ ctx: typer.Context,
501
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max keys to check.")] = 200,
502
+ ) -> None:
503
+ """Find keys without TTL (potential memory leaks)."""
504
+ actx: AppContext = ctx.obj
505
+ out = actx.output
506
+
507
+ redis_url = actx.redis_url
508
+ if not redis_url:
509
+ out.error("No redis_url configured.")
510
+ raise typer.Exit(1)
511
+
512
+ async def _run() -> tuple[list[str], int]:
513
+ from kctl_api.core.redis import close_redis, get_redis
514
+
515
+ try:
516
+ client = get_redis(redis_url)
517
+ no_ttl: list[str] = []
518
+ total = 0
519
+
520
+ async for key in client.scan_iter(match="*", count=100):
521
+ total += 1
522
+ if total > limit:
523
+ break
524
+ ttl = await client.ttl(key)
525
+ if ttl == -1: # -1 means no TTL, -2 means key doesn't exist
526
+ no_ttl.append(str(key))
527
+
528
+ return no_ttl, total
529
+ finally:
530
+ await close_redis()
531
+
532
+ try:
533
+ no_ttl_keys, total_checked = asyncio.run(_run())
534
+ except Exception as e:
535
+ out.error(f"Redis error: {e}")
536
+ raise typer.Exit(1) from None
537
+
538
+ out.info(f"Checked {total_checked} keys — {len(no_ttl_keys)} without TTL.")
539
+
540
+ if not no_ttl_keys:
541
+ out.success("All sampled keys have TTL set.")
542
+ return
543
+
544
+ rows = [[k] for k in no_ttl_keys[:50]]
545
+ out.table(
546
+ title=f"Keys Without TTL ({len(no_ttl_keys)} found)",
547
+ columns=[("Key", "yellow")],
548
+ rows=rows,
549
+ data_for_json=[{"key": k} for k in no_ttl_keys],
550
+ )
551
+ out.warn(f"{len(no_ttl_keys)} keys without TTL may cause unbounded memory growth.")
552
+
553
+
554
+ # ---------------------------------------------------------------------------
555
+ # keys-by-prefix
556
+ # ---------------------------------------------------------------------------
557
+ @app.command(name="keys-by-prefix")
558
+ def keys_by_prefix(
559
+ ctx: typer.Context,
560
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Max keys to scan.")] = 1000,
561
+ ) -> None:
562
+ """Group Redis keys by prefix (up to first colon)."""
563
+ actx: AppContext = ctx.obj
564
+ out = actx.output
565
+
566
+ redis_url = actx.redis_url
567
+ if not redis_url:
568
+ out.error("No redis_url configured.")
569
+ raise typer.Exit(1)
570
+
571
+ async def _run() -> dict[str, int]:
572
+ from kctl_api.core.redis import close_redis, get_redis
573
+
574
+ try:
575
+ client = get_redis(redis_url)
576
+ prefix_counts: dict[str, int] = {}
577
+ count = 0
578
+
579
+ async for key in client.scan_iter(match="*", count=100):
580
+ count += 1
581
+ if count > limit:
582
+ break
583
+ key_str = str(key)
584
+ prefix = key_str.split(":")[0] if ":" in key_str else f"(no prefix) {key_str[:20]}"
585
+ prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
586
+
587
+ return prefix_counts
588
+ finally:
589
+ await close_redis()
590
+
591
+ try:
592
+ prefix_counts = asyncio.run(_run())
593
+ except Exception as e:
594
+ out.error(f"Redis error: {e}")
595
+ raise typer.Exit(1) from None
596
+
597
+ if not prefix_counts:
598
+ out.info("No keys found.")
599
+ return
600
+
601
+ sorted_prefixes = sorted(prefix_counts.items(), key=lambda x: -x[1])
602
+
603
+ rows = [[prefix, str(count), "#" * min(count, 30)] for prefix, count in sorted_prefixes]
604
+ out.table(
605
+ title=f"Keys by Prefix (sampled {limit})",
606
+ columns=[("Prefix", "bold"), ("Count", ""), ("Bar", "green")],
607
+ rows=rows,
608
+ data_for_json=[{"prefix": p, "count": c} for p, c in sorted_prefixes],
609
+ )