kx-cli 0.0.9__tar.gz → 0.0.10__tar.gz

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 (102) hide show
  1. {kx_cli-0.0.9/src/kx_cli.egg-info → kx_cli-0.0.10}/PKG-INFO +2 -2
  2. {kx_cli-0.0.9 → kx_cli-0.0.10}/README.md +1 -1
  3. {kx_cli-0.0.9 → kx_cli-0.0.10}/pyproject.toml +1 -1
  4. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/events.py +16 -9
  5. kx_cli-0.0.10/src/kx/commands/yaml.py +43 -0
  6. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/config.py +7 -0
  7. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/console.py +25 -45
  8. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/events.py +16 -1
  9. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/index.py +13 -0
  10. kx_cli-0.0.10/src/kx/kubectl.py +64 -0
  11. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/main.py +4 -7
  12. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/scanner.py +12 -2
  13. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/state.py +30 -7
  14. {kx_cli-0.0.9 → kx_cli-0.0.10/src/kx_cli.egg-info}/PKG-INFO +2 -2
  15. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/SOURCES.txt +1 -0
  16. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_config.py +34 -0
  17. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_console.py +82 -15
  18. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_events.py +37 -11
  19. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_index.py +20 -0
  20. kx_cli-0.0.10/tests/test_kubectl.py +87 -0
  21. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_scanner.py +14 -1
  22. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_state.py +59 -0
  23. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_yaml.py +22 -0
  24. kx_cli-0.0.9/src/kx/commands/yaml.py +0 -32
  25. kx_cli-0.0.9/src/kx/kubectl.py +0 -49
  26. {kx_cli-0.0.9 → kx_cli-0.0.10}/LICENSE +0 -0
  27. {kx_cli-0.0.9 → kx_cli-0.0.10}/setup.cfg +0 -0
  28. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/__init__.py +0 -0
  29. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/__init__.py +0 -0
  30. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/_metadata.py +0 -0
  31. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/annotations.py +0 -0
  32. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/back.py +0 -0
  33. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/context.py +0 -0
  34. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/contexts.py +0 -0
  35. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/delete.py +0 -0
  36. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/describe.py +0 -0
  37. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/diagnostic.py +0 -0
  38. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/drop.py +0 -0
  39. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/edit.py +0 -0
  40. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/exec.py +0 -0
  41. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/forward.py +0 -0
  42. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/get.py +0 -0
  43. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/labels.py +0 -0
  44. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/logs.py +0 -0
  45. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/metadata_write.py +0 -0
  46. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/namespace.py +0 -0
  47. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/port_forward.py +0 -0
  48. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/rollout.py +0 -0
  49. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/scale.py +0 -0
  50. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/scan.py +0 -0
  51. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/state.py +0 -0
  52. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/theme.py +0 -0
  53. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/top.py +0 -0
  54. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/tree.py +0 -0
  55. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/diagnostics.py +0 -0
  56. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/errors.py +0 -0
  57. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/graph.py +0 -0
  58. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/k8s.py +0 -0
  59. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/kinds.py +0 -0
  60. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/refresh.py +0 -0
  61. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/themes.py +0 -0
  62. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/types.py +0 -0
  63. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/dependency_links.txt +0 -0
  64. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/entry_points.txt +0 -0
  65. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/requires.txt +0 -0
  66. {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/top_level.txt +0 -0
  67. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_annotations.py +0 -0
  68. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_back.py +0 -0
  69. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_context.py +0 -0
  70. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_get.py +0 -0
  71. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_help.py +0 -0
  72. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_kind_alias.py +0 -0
  73. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_label_annotate.py +0 -0
  74. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_top.py +0 -0
  75. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_context.py +0 -0
  76. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_contexts.py +0 -0
  77. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_delete.py +0 -0
  78. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_describe.py +0 -0
  79. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_diagnostic.py +0 -0
  80. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_diagnostics.py +0 -0
  81. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_drop.py +0 -0
  82. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_edit.py +0 -0
  83. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_error_handling.py +0 -0
  84. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_exec.py +0 -0
  85. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_forward.py +0 -0
  86. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_get.py +0 -0
  87. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_graph.py +0 -0
  88. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_kinds.py +0 -0
  89. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_labels.py +0 -0
  90. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_logs.py +0 -0
  91. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_metadata_helper.py +0 -0
  92. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_metadata_write.py +0 -0
  93. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_namespace.py +0 -0
  94. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_port_forward.py +0 -0
  95. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_refresh.py +0 -0
  96. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_rollout.py +0 -0
  97. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_scale.py +0 -0
  98. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_scan.py +0 -0
  99. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_theme.py +0 -0
  100. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_themes.py +0 -0
  101. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_top.py +0 -0
  102. {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_tree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kx-cli
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: kubectl wrapper with index-based resource selection
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -70,7 +70,7 @@ With pip:
70
70
  pip install kx-cli
71
71
  ```
72
72
 
73
- As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; krew-index submission in progress):
73
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required):
74
74
 
75
75
  ```bash
76
76
  kubectl krew install idx
@@ -47,7 +47,7 @@ With pip:
47
47
  pip install kx-cli
48
48
  ```
49
49
 
50
- As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; krew-index submission in progress):
50
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required):
51
51
 
52
52
  ```bash
53
53
  kubectl krew install idx
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kx-cli"
3
- version = "0.0.9"
3
+ version = "0.0.10"
4
4
  description = "kubectl wrapper with index-based resource selection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,4 +1,4 @@
1
- from kx.events import EventsServiceProtocol
1
+ from kx.events import EventRow, EventsServiceProtocol
2
2
  from kx.kubectl import KubectlServiceProtocol
3
3
  from kx.refresh import ensure_exists
4
4
  from kx.state import StateServiceProtocol
@@ -15,7 +15,7 @@ class EventsCommand:
15
15
  self.events = events
16
16
  self.kubectl = kubectl
17
17
 
18
- def execute(self, index: int) -> str:
18
+ def execute(self, index: int) -> list[EventRow]:
19
19
  name, namespace, kind = self.state.fields(index)
20
20
  all_events = self.events.get(namespace)
21
21
  filtered = self.events.filter(all_events, name, kind)
@@ -24,14 +24,21 @@ class EventsCommand:
24
24
  # Deleted resources keep their events ~1h; only an empty result
25
25
  # warrants checking whether the target itself is gone.
26
26
  ensure_exists(self.kubectl, kind, name, namespace)
27
- return "No events found"
27
+ return []
28
28
 
29
- output = []
29
+ rows = []
30
30
  for event in filtered:
31
31
  obj = event.involved_object
32
- output.append(
33
- f"{event.type:8} {event.reason:30} "
34
- f"{obj.kind:10} {event.metadata.creation_timestamp} "
35
- f"{event.message}"
32
+ timestamp = getattr(event, "last_timestamp", None) or getattr(
33
+ event.metadata, "creation_timestamp", None
36
34
  )
37
- return "\n".join(output)
35
+ rows.append(
36
+ EventRow(
37
+ type=event.type or "",
38
+ reason=event.reason or "",
39
+ kind=obj.kind,
40
+ message=event.message or "",
41
+ timestamp=timestamp,
42
+ )
43
+ )
44
+ return rows
@@ -0,0 +1,43 @@
1
+ import yaml
2
+
3
+ from kx.kubectl import KubectlServiceProtocol
4
+ from kx.state import StateServiceProtocol
5
+
6
+
7
+ def _find_keys(data: dict | list, keys: set[str]) -> dict:
8
+ """Collect the value of each requested key from anywhere in the manifest,
9
+ preferring the shallowest occurrence when a key appears at multiple depths.
10
+
11
+ A breadth-first walk guarantees shallowest-wins: a workload's own top-level
12
+ `metadata` is returned rather than its pod template's nested `metadata`,
13
+ while genuinely nested-only keys (e.g. `containerStatuses` under `status`)
14
+ are still found. A matched key's own subtree is not descended into."""
15
+ result: dict = {}
16
+ frontier: list = [data]
17
+ while frontier:
18
+ deeper: list = []
19
+ for node in frontier:
20
+ if isinstance(node, dict):
21
+ for k, v in node.items():
22
+ if k in keys:
23
+ result.setdefault(k, v)
24
+ else:
25
+ deeper.append(v)
26
+ elif isinstance(node, list):
27
+ deeper.extend(node)
28
+ frontier = deeper
29
+ return result
30
+
31
+
32
+ class YamlCommand:
33
+ def __init__(self, state: StateServiceProtocol, kubectl: KubectlServiceProtocol):
34
+ self.state = state
35
+ self.kubectl = kubectl
36
+
37
+ def execute(self, index: int, show: list[str] | None = None) -> str:
38
+ name, namespace, kind = self.state.fields(index)
39
+ raw = self.kubectl.run(["get", kind, name, "-n", namespace, "-o", "yaml"])
40
+ if not show:
41
+ return raw
42
+ data = yaml.safe_load(raw)
43
+ return yaml.dump(_find_keys(data, set(show)), default_flow_style=False)
@@ -52,6 +52,13 @@ def load_config() -> Config:
52
52
  if "KX_THEME" in os.environ:
53
53
  kwargs["theme"] = os.environ["KX_THEME"]
54
54
 
55
+ if "max_history" in kwargs:
56
+ mh = kwargs["max_history"]
57
+ # bool is an int subclass; reject it and any non-int/non-positive value
58
+ # (a string from TOML, 0, or a negative) with a clear message.
59
+ if isinstance(mh, bool) or not isinstance(mh, int) or mh < 1:
60
+ raise SystemExit("kx: max_history must be a positive integer")
61
+
55
62
  if "theme" in kwargs and kwargs["theme"] not in THEMES:
56
63
  raise SystemExit(
57
64
  f"kx: unknown theme '{kwargs['theme']}' (run kx theme to list themes)"
@@ -14,6 +14,8 @@ from rich.table import Table
14
14
  from rich.text import Text
15
15
 
16
16
  from kx.diagnostics import SEVERITY_PATTERN, Severity
17
+ from kx.events import EventRow
18
+ from kx.index import _parse_output
17
19
  from kx.kinds import plural_display
18
20
  from kx.scanner import SEVERITIES, ImageScan
19
21
  from kx.state import StateHistory
@@ -245,28 +247,20 @@ def _print_get_caption(resource_type: str, namespace: str, count: int) -> None:
245
247
  def render_indexed_table(
246
248
  text: str, resource_type: str, namespace: str, note: str | None = None
247
249
  ) -> None:
248
- lines = [line for line in text.splitlines() if line.strip()]
249
- if not lines:
250
- # kubectl exits 0 with empty stdout when nothing matches ("No
251
- # resources found" goes to stderr) — show the caption, not silence.
252
- _print_get_caption(resource_type, namespace, 0)
253
- return
254
-
255
- header_line = lines[0]
256
- first_col = header_line.split()[0] if header_line.split() else ""
257
- if first_col != "X" and note is None:
258
- _console.print(text, markup=False, highlight=False)
250
+ # Share index._parse_output (the single table parser) rather than a private
251
+ # copy: it extends the last column to end-of-line, so a value wider than its
252
+ # header (kubectl doesn't pad the last column) is never sliced off.
253
+ headers, rows, _ = _parse_output(text)
254
+ if not headers:
255
+ # Non-tabular output (JSON/YAML, or a table with no NAME column) prints
256
+ # as-is; genuinely empty stdout (kubectl sends "No resources found" to
257
+ # stderr) shows the zero-count caption instead of silence.
258
+ if text.strip():
259
+ _console.print(text, markup=False, highlight=False)
260
+ else:
261
+ _print_get_caption(resource_type, namespace, 0)
259
262
  return
260
263
 
261
- spans = [(m.start(), m.end()) for m in re.finditer(r"\S+\s*", header_line)]
262
- headers = [header_line[start:end].strip() for start, end in spans]
263
-
264
- rows = []
265
- for line in lines[1:]:
266
- cols = [line[start:end].strip() for start, end in spans]
267
- if cols:
268
- rows.append(cols)
269
-
270
264
  if not rows:
271
265
  _print_get_caption(resource_type, namespace, 0)
272
266
  return
@@ -329,8 +323,8 @@ def render_indexed_table(
329
323
  _console.print(f"[muted]{note}[/muted]")
330
324
 
331
325
 
332
- def render_events_table(text: str) -> None:
333
- if text.strip() == "No events found":
326
+ def render_events_table(rows: list[EventRow]) -> None:
327
+ if not rows:
334
328
  _console.print("[muted]No events found[/muted]")
335
329
  return
336
330
 
@@ -343,31 +337,17 @@ def render_events_table(text: str) -> None:
343
337
  for col in ("TYPE", "REASON", "KIND", "AGE", "MESSAGE"):
344
338
  table.add_column(col)
345
339
 
346
- for line in text.splitlines():
347
- if not line.strip():
348
- continue
349
- event_type = line[0:8].strip()
350
- reason = line[9:39].strip()
351
- kind = line[40:50].strip()
352
- rest = line[51:]
353
- parts = rest.split(" ", 2)
354
- timestamp = (
355
- f"{parts[0]} {parts[1]}" if len(parts) >= 2 else (parts[0] if parts else "")
356
- )
357
- message = parts[2] if len(parts) >= 3 else ""
358
-
359
- try:
360
- age = _format_age(datetime.fromisoformat(timestamp))
361
- except ValueError:
362
- age = timestamp
363
-
364
- type_color = "muted" if event_type == "Normal" else "warn"
340
+ for row in rows:
341
+ age = _format_age(row.timestamp)
342
+ type_color = "muted" if row.type == "Normal" else "warn"
365
343
  table.add_row(
366
- f"[{type_color}]{event_type}[/]",
367
- reason,
368
- kind,
344
+ f"[{type_color}]{row.type}[/]",
345
+ row.reason,
346
+ row.kind,
369
347
  f"[muted]{age}[/]",
370
- message,
348
+ # Text (not markup) so bracketed characters in a message are shown
349
+ # literally, matching the WARNING EVENTS section.
350
+ Text(row.message),
371
351
  )
372
352
 
373
353
  _console.print(table)
@@ -1,9 +1,24 @@
1
- from kubernetes import client
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
2
3
  from typing import Protocol
3
4
 
5
+ from kubernetes import client
6
+
4
7
  from kx.k8s import load_config
5
8
 
6
9
 
10
+ @dataclass
11
+ class EventRow:
12
+ """One event, flattened for rendering. `timestamp` is the event's last-seen
13
+ time (falling back to creation) as a datetime, or None if unavailable."""
14
+
15
+ type: str
16
+ reason: str
17
+ kind: str
18
+ message: str
19
+ timestamp: datetime | None = None
20
+
21
+
7
22
  class EventsServiceProtocol(Protocol):
8
23
  def get(self, namespace: str) -> list: ...
9
24
  def filter(self, events: list, name: str, kind: str) -> list: ...
@@ -63,6 +63,19 @@ class IndexService:
63
63
  if not headers:
64
64
  return output, []
65
65
 
66
+ # Index numbers must map 1:1 to saved state, which is keyed by name.
67
+ # Collapse any repeated NAME (first-seen wins) so the displayed indexes
68
+ # and the state entries can never desync.
69
+ seen: set[str] = set()
70
+ unique_rows = []
71
+ for row in rows:
72
+ name = row[name_idx]
73
+ if name in seen:
74
+ continue
75
+ seen.add(name)
76
+ unique_rows.append(row)
77
+ rows = unique_rows
78
+
66
79
  names = [row[name_idx] for row in rows]
67
80
 
68
81
  headers = ["X"] + headers
@@ -0,0 +1,64 @@
1
+ import subprocess
2
+ from typing import Protocol
3
+
4
+ _MISSING_KUBECTL = (
5
+ "kubectl not found on PATH — install kubectl "
6
+ "(https://kubernetes.io/docs/tasks/tools/) and ensure it is on your PATH."
7
+ )
8
+
9
+
10
+ class KubectlServiceProtocol(Protocol):
11
+ def run(self, args: list[str]) -> str: ...
12
+ def run_interactive(self, args: list[str], stderr: int | None = None) -> int: ...
13
+ def probe(self, args: list[str]) -> int: ...
14
+ def current_namespace(self) -> str: ...
15
+ def current_context(self) -> str: ...
16
+
17
+
18
+ class KubectlService:
19
+ def _run(self, args: list[str], **kwargs) -> subprocess.CompletedProcess:
20
+ # Central chokepoint: a missing kubectl raises FileNotFoundError, which
21
+ # handle_errors doesn't catch (traceback). Translate it into a handled
22
+ # RuntimeError with an actionable message.
23
+ try:
24
+ return subprocess.run(["kubectl", *args], **kwargs)
25
+ except FileNotFoundError as e:
26
+ raise RuntimeError(_MISSING_KUBECTL) from e
27
+
28
+ def run(self, args: list[str]) -> str:
29
+ result = self._run(args, capture_output=True, text=True)
30
+ if result.returncode != 0:
31
+ raise RuntimeError(result.stderr.strip())
32
+ return result.stdout
33
+
34
+ def run_interactive(self, args: list[str], stderr: int | None = None) -> int:
35
+ return self._run(args, stderr=stderr).returncode
36
+
37
+ def probe(self, args: list[str]) -> int:
38
+ return self._run(args, capture_output=True).returncode
39
+
40
+ def current_namespace(self) -> str:
41
+ # Best-effort: no kubeconfig / no current context exits non-zero, and
42
+ # `check=True` would surface that as an unhandled CalledProcessError.
43
+ # The namespace is only a label here, so fall back to "default".
44
+ result = self._run(
45
+ ["config", "view", "--minify", "-o", "jsonpath={..namespace}"],
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ if result.returncode != 0:
50
+ return "default"
51
+ ns = result.stdout.strip()
52
+ return ns if ns else "default"
53
+
54
+ def current_context(self) -> str:
55
+ # `kubectl config current-context` exits non-zero when none is set;
56
+ # return empty so context listing still works instead of crashing.
57
+ result = self._run(
58
+ ["config", "current-context"],
59
+ capture_output=True,
60
+ text=True,
61
+ )
62
+ if result.returncode != 0:
63
+ return ""
64
+ return result.stdout.strip()
@@ -293,16 +293,13 @@ def events(indexes: list[int]):
293
293
  for position, index in enumerate(indexes):
294
294
  name, ns, kind = _state.fields(index)
295
295
  with console.status("fetching events"):
296
- result = command.execute(index)
297
- if result.strip() == "No events found":
298
- count = 0
299
- else:
300
- count = len([line for line in result.splitlines() if line.strip()])
296
+ rows = command.execute(index)
297
+ count = len(rows)
301
298
  extra = f"{count} {'item' if count == 1 else 'items'}" if count else ""
302
299
  if position > 0:
303
300
  console.print_raw("")
304
301
  console.print_banner(kind, name, namespace=ns, extra=extra)
305
- console.render_events_table(result)
302
+ console.render_events_table(rows)
306
303
 
307
304
 
308
305
  @app.command(
@@ -468,7 +465,7 @@ def yaml(
468
465
  show: Optional[str] = typer.Option(
469
466
  None,
470
467
  "--show",
471
- help="Comma-separated top-level YAML fields to display (e.g. metadata,spec)",
468
+ help="Comma-separated YAML fields to display (e.g. metadata,spec)",
472
469
  ),
473
470
  ):
474
471
  """Print the raw YAML manifest for one or more indexed resources; --show filters to specific top-level fields."""
@@ -24,15 +24,25 @@ class ScannerServiceProtocol(Protocol):
24
24
  def capture(self, argv: list[str]) -> subprocess.CompletedProcess: ...
25
25
 
26
26
 
27
+ def _missing_binary(argv: list[str]) -> str:
28
+ return f"{argv[0]} not found on PATH — install it to run this scan."
29
+
30
+
27
31
  class ScannerService:
28
32
  def scan(self, argv: list[str]) -> int:
29
33
  # Inherit stdio so the scanner streams its own output straight to the
30
34
  # terminal (native passthrough); return the exit code without raising.
31
- return subprocess.run(argv).returncode
35
+ try:
36
+ return subprocess.run(argv).returncode
37
+ except FileNotFoundError as e:
38
+ raise RuntimeError(_missing_binary(argv)) from e
32
39
 
33
40
  def capture(self, argv: list[str]) -> subprocess.CompletedProcess:
34
41
  # Capture stdout for structured (SARIF) parsing into a summary table.
35
- return subprocess.run(argv, capture_output=True, text=True)
42
+ try:
43
+ return subprocess.run(argv, capture_output=True, text=True)
44
+ except FileNotFoundError as e:
45
+ raise RuntimeError(_missing_binary(argv)) from e
36
46
 
37
47
 
38
48
  class Engine:
@@ -1,5 +1,7 @@
1
1
  from dataclasses import dataclass, asdict
2
2
  import json
3
+ import os
4
+ import tempfile
3
5
  from pathlib import Path
4
6
  from typing import Protocol
5
7
 
@@ -55,16 +57,26 @@ _STATE_FILE = Path.home() / ".kx" / "state.json"
55
57
 
56
58
  class StateService:
57
59
  def __init__(self, max_history: int = 10) -> None:
58
- self.max_history = max_history
60
+ # Always keep at least one entry: max_history < 1 would otherwise make
61
+ # the history slice below a no-op (list[-0:] is the whole list).
62
+ self.max_history = max(1, max_history)
59
63
 
60
64
  def _load_history(self) -> StateHistory:
61
65
  if not _STATE_FILE.exists():
62
66
  raise RuntimeError("No state found. Run `kx get <resource>` first.")
63
- data = json.loads(_STATE_FILE.read_text())
64
- if "states" not in data:
65
- return StateHistory(states=[_state_from_dict(data)], cursor=0)
66
- states = [_state_from_dict(state_data) for state_data in data["states"]]
67
- return StateHistory(states=states, cursor=data["cursor"])
67
+ try:
68
+ data = json.loads(_STATE_FILE.read_text())
69
+ if "states" not in data:
70
+ return StateHistory(states=[_state_from_dict(data)], cursor=0)
71
+ states = [_state_from_dict(state_data) for state_data in data["states"]]
72
+ return StateHistory(states=states, cursor=data["cursor"])
73
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
74
+ # Corrupt or foreign JSON (partial write, hand-edit, old schema).
75
+ # A `kx get` rebuilds state, so this is recoverable, not fatal.
76
+ raise RuntimeError(
77
+ f"State file at {_STATE_FILE} is unreadable ({e}). "
78
+ f"Run `kx get <resource>` to rebuild it."
79
+ ) from e
68
80
 
69
81
  def _save_history(self, history: StateHistory) -> None:
70
82
  _STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -72,7 +84,18 @@ class StateService:
72
84
  "states": [asdict(state) for state in history.states],
73
85
  "cursor": history.cursor,
74
86
  }
75
- _STATE_FILE.write_text(json.dumps(data))
87
+ # Write to a sibling temp file and atomically replace it in, so an
88
+ # interrupted write can never leave a truncated/corrupt state.json.
89
+ fd, tmp = tempfile.mkstemp(
90
+ dir=_STATE_FILE.parent, prefix=".state-", suffix=".tmp"
91
+ )
92
+ try:
93
+ with os.fdopen(fd, "w") as f:
94
+ f.write(json.dumps(data))
95
+ os.replace(tmp, _STATE_FILE)
96
+ except BaseException:
97
+ Path(tmp).unlink(missing_ok=True)
98
+ raise
76
99
 
77
100
  def save(self, state: State) -> None:
78
101
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kx-cli
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: kubectl wrapper with index-based resource selection
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -70,7 +70,7 @@ With pip:
70
70
  pip install kx-cli
71
71
  ```
72
72
 
73
- As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required; krew-index submission in progress):
73
+ As a kubectl plugin via [krew](https://krew.sigs.k8s.io/), where kx is published as `idx` (no Python required):
74
74
 
75
75
  ```bash
76
76
  kubectl krew install idx
@@ -78,6 +78,7 @@ tests/test_get.py
78
78
  tests/test_graph.py
79
79
  tests/test_index.py
80
80
  tests/test_kinds.py
81
+ tests/test_kubectl.py
81
82
  tests/test_labels.py
82
83
  tests/test_logs.py
83
84
  tests/test_metadata_helper.py
@@ -90,6 +90,40 @@ class TestEnvOverrides:
90
90
  with pytest.raises(SystemExit, match="KX_MAX_HISTORY must be an integer"):
91
91
  load_config()
92
92
 
93
+ def test_zero_max_history_from_env_raises_system_exit(self, tmp_path, monkeypatch):
94
+ _clear_env(monkeypatch)
95
+ monkeypatch.setenv("KX_MAX_HISTORY", "0")
96
+ with _patch_config_file(tmp_path / "config.toml"):
97
+ with pytest.raises(SystemExit, match="positive integer"):
98
+ load_config()
99
+
100
+ def test_negative_max_history_from_env_raises_system_exit(
101
+ self, tmp_path, monkeypatch
102
+ ):
103
+ _clear_env(monkeypatch)
104
+ monkeypatch.setenv("KX_MAX_HISTORY", "-3")
105
+ with _patch_config_file(tmp_path / "config.toml"):
106
+ with pytest.raises(SystemExit, match="positive integer"):
107
+ load_config()
108
+
109
+ def test_zero_max_history_from_file_raises_system_exit(self, tmp_path, monkeypatch):
110
+ _clear_env(monkeypatch)
111
+ config_file = tmp_path / "config.toml"
112
+ config_file.write_text("max_history = 0\n")
113
+ with _patch_config_file(config_file):
114
+ with pytest.raises(SystemExit, match="positive integer"):
115
+ load_config()
116
+
117
+ def test_non_integer_max_history_from_file_raises_system_exit(
118
+ self, tmp_path, monkeypatch
119
+ ):
120
+ _clear_env(monkeypatch)
121
+ config_file = tmp_path / "config.toml"
122
+ config_file.write_text('max_history = "ten"\n')
123
+ with _patch_config_file(config_file):
124
+ with pytest.raises(SystemExit, match="positive integer"):
125
+ load_config()
126
+
93
127
 
94
128
  class TestNoColor:
95
129
  def test_no_color_from_file(self, tmp_path, monkeypatch):