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.
- {kx_cli-0.0.9/src/kx_cli.egg-info → kx_cli-0.0.10}/PKG-INFO +2 -2
- {kx_cli-0.0.9 → kx_cli-0.0.10}/README.md +1 -1
- {kx_cli-0.0.9 → kx_cli-0.0.10}/pyproject.toml +1 -1
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/events.py +16 -9
- kx_cli-0.0.10/src/kx/commands/yaml.py +43 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/config.py +7 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/console.py +25 -45
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/events.py +16 -1
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/index.py +13 -0
- kx_cli-0.0.10/src/kx/kubectl.py +64 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/main.py +4 -7
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/scanner.py +12 -2
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/state.py +30 -7
- {kx_cli-0.0.9 → kx_cli-0.0.10/src/kx_cli.egg-info}/PKG-INFO +2 -2
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/SOURCES.txt +1 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_config.py +34 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_console.py +82 -15
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_events.py +37 -11
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_index.py +20 -0
- kx_cli-0.0.10/tests/test_kubectl.py +87 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_scanner.py +14 -1
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_state.py +59 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_yaml.py +22 -0
- kx_cli-0.0.9/src/kx/commands/yaml.py +0 -32
- kx_cli-0.0.9/src/kx/kubectl.py +0 -49
- {kx_cli-0.0.9 → kx_cli-0.0.10}/LICENSE +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/setup.cfg +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/__init__.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/__init__.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/_metadata.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/annotations.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/back.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/context.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/contexts.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/delete.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/describe.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/diagnostic.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/drop.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/edit.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/exec.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/forward.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/get.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/labels.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/logs.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/metadata_write.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/namespace.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/port_forward.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/rollout.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/scale.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/scan.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/state.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/theme.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/top.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/commands/tree.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/diagnostics.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/errors.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/graph.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/k8s.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/kinds.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/refresh.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/themes.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx/types.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/dependency_links.txt +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/entry_points.txt +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/requires.txt +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/src/kx_cli.egg-info/top_level.txt +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_annotations.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_back.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_context.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_get.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_help.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_kind_alias.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_label_annotate.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_cli_top.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_context.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_contexts.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_delete.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_describe.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_diagnostic.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_diagnostics.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_drop.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_edit.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_error_handling.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_exec.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_forward.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_get.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_graph.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_kinds.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_labels.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_logs.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_metadata_helper.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_metadata_write.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_namespace.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_port_forward.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_refresh.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_rollout.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_scale.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_scan.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_theme.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_themes.py +0 -0
- {kx_cli-0.0.9 → kx_cli-0.0.10}/tests/test_top.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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,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) ->
|
|
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
|
|
27
|
+
return []
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
rows = []
|
|
30
30
|
for event in filtered:
|
|
31
31
|
obj = event.involved_object
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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(
|
|
333
|
-
if
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
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}]{
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
@@ -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):
|