kx-cli 0.0.5__tar.gz → 0.0.6__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 (82) hide show
  1. {kx_cli-0.0.5/src/kx_cli.egg-info → kx_cli-0.0.6}/PKG-INFO +35 -6
  2. {kx_cli-0.0.5 → kx_cli-0.0.6}/README.md +33 -5
  3. {kx_cli-0.0.5 → kx_cli-0.0.6}/pyproject.toml +2 -1
  4. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/describe.py +2 -1
  5. kx_cli-0.0.6/src/kx/commands/diagnostic.py +195 -0
  6. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/edit.py +2 -1
  7. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/exec.py +2 -1
  8. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/get.py +2 -1
  9. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/logs.py +2 -1
  10. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/port_forward.py +4 -1
  11. kx_cli-0.0.6/src/kx/commands/theme.py +23 -0
  12. kx_cli-0.0.6/src/kx/config.py +82 -0
  13. kx_cli-0.0.6/src/kx/console.py +654 -0
  14. kx_cli-0.0.6/src/kx/diagnostics.py +363 -0
  15. kx_cli-0.0.6/src/kx/errors.py +27 -0
  16. kx_cli-0.0.6/src/kx/graph.py +186 -0
  17. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/index.py +8 -14
  18. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/main.py +178 -102
  19. kx_cli-0.0.6/src/kx/themes.py +149 -0
  20. {kx_cli-0.0.5 → kx_cli-0.0.6/src/kx_cli.egg-info}/PKG-INFO +35 -6
  21. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/SOURCES.txt +15 -0
  22. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/requires.txt +1 -0
  23. kx_cli-0.0.6/tests/test_cli_help.py +48 -0
  24. kx_cli-0.0.6/tests/test_config.py +183 -0
  25. kx_cli-0.0.6/tests/test_console.py +622 -0
  26. kx_cli-0.0.6/tests/test_delete.py +34 -0
  27. kx_cli-0.0.6/tests/test_diagnostic.py +241 -0
  28. kx_cli-0.0.6/tests/test_diagnostics.py +415 -0
  29. kx_cli-0.0.6/tests/test_error_handling.py +94 -0
  30. kx_cli-0.0.6/tests/test_events.py +69 -0
  31. kx_cli-0.0.6/tests/test_graph.py +233 -0
  32. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_index.py +3 -4
  33. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_state.py +2 -3
  34. kx_cli-0.0.6/tests/test_theme.py +40 -0
  35. kx_cli-0.0.6/tests/test_themes.py +36 -0
  36. kx_cli-0.0.6/tests/test_tree.py +59 -0
  37. kx_cli-0.0.5/src/kx/config.py +0 -38
  38. kx_cli-0.0.5/src/kx/console.py +0 -368
  39. kx_cli-0.0.5/src/kx/graph.py +0 -231
  40. kx_cli-0.0.5/tests/test_config.py +0 -87
  41. kx_cli-0.0.5/tests/test_console.py +0 -202
  42. {kx_cli-0.0.5 → kx_cli-0.0.6}/LICENSE +0 -0
  43. {kx_cli-0.0.5 → kx_cli-0.0.6}/setup.cfg +0 -0
  44. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/__init__.py +0 -0
  45. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/__init__.py +0 -0
  46. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/back.py +0 -0
  47. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/delete.py +0 -0
  48. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/drop.py +0 -0
  49. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/events.py +0 -0
  50. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/forward.py +0 -0
  51. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/labels.py +0 -0
  52. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/namespace.py +0 -0
  53. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/rollout.py +0 -0
  54. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/scale.py +0 -0
  55. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/state.py +0 -0
  56. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/tree.py +0 -0
  57. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/commands/yaml.py +0 -0
  58. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/events.py +0 -0
  59. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/k8s.py +0 -0
  60. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/kinds.py +0 -0
  61. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/kubectl.py +0 -0
  62. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/state.py +0 -0
  63. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx/types.py +0 -0
  64. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/dependency_links.txt +0 -0
  65. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/entry_points.txt +0 -0
  66. {kx_cli-0.0.5 → kx_cli-0.0.6}/src/kx_cli.egg-info/top_level.txt +0 -0
  67. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_back.py +0 -0
  68. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_cli_get.py +0 -0
  69. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_describe.py +0 -0
  70. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_drop.py +0 -0
  71. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_edit.py +0 -0
  72. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_exec.py +0 -0
  73. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_forward.py +0 -0
  74. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_get.py +0 -0
  75. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_kinds.py +0 -0
  76. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_labels.py +0 -0
  77. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_logs.py +0 -0
  78. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_namespace.py +0 -0
  79. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_port_forward.py +0 -0
  80. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_rollout.py +0 -0
  81. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_scale.py +0 -0
  82. {kx_cli-0.0.5 → kx_cli-0.0.6}/tests/test_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kx-cli
3
- Version: 0.0.5
3
+ Version: 0.0.6
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
@@ -16,6 +16,7 @@ Provides-Extra: dev
16
16
  Requires-Dist: ruff; extra == "dev"
17
17
  Requires-Dist: pre-commit; extra == "dev"
18
18
  Requires-Dist: pytest; extra == "dev"
19
+ Requires-Dist: pytest-cov; extra == "dev"
19
20
  Dynamic: license-file
20
21
 
21
22
  <div align="center">
@@ -63,14 +64,17 @@ Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespac
63
64
 
64
65
  ```
65
66
  $ kx get pods
66
- X NAME READY STATUS RESTARTS AGE
67
- 1 api-7d9f4b8c6-xkp2q 1/1 Running 0 2d
68
- 2 worker-6c8b5f7d9-mnt4r 1/1 Running 3 5h
69
- 3 postgres-0 1/1 Running 0 12d
67
+ Pods · default · 3 items
68
+ X NAME READY STATUS RESTARTS AGE
69
+ 1 api-7d9f4b8c6-xkp2q 1/1 Running 0 2d
70
+ 2 worker-6c8b5f7d9-mnt4r 1/1 Running 3 5h
71
+ 3 postgres-0 1/1 Running 0 12d
70
72
  ```
71
73
 
72
74
  All subsequent commands reference resources by their `X` index from the last `kx get`.
73
75
 
76
+ Global flags: `--no-color` disables styled output, `--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
77
+
74
78
  ### Commands
75
79
 
76
80
  <!-- commands-table-start -->
@@ -89,7 +93,9 @@ All subsequent commands reference resources by their `X` index from the last `kx
89
93
  | `kx rollout <action> <index>` | Run a rollout action (status, restart, pause, resume, history, undo) on a Deployment, StatefulSet, or DaemonSet. |
90
94
  | `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
91
95
  | `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
96
+ | `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
92
97
  | `kx namespace <index>` | Switch to an indexed namespace; alias: kx ns (run kx get namespaces first). |
98
+ | `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
93
99
  | `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
94
100
  | `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
95
101
  | `kx back` | Navigate to the previous kx get result. |
@@ -128,7 +134,7 @@ kx delete 3
128
134
 
129
135
  ## State
130
136
 
131
- `kx` maintains a history of up to 10 `kx get` results in `~/.kx_state.json`. A cursor tracks your current position; index-based commands always resolve against the entry at the cursor.
137
+ `kx` maintains a history of up to 10 `kx get` results in `~/.kx/state.json`. A cursor tracks your current position; index-based commands always resolve against the entry at the cursor.
132
138
 
133
139
  ```
134
140
  $ kx get pods # saves a new entry, cursor advances
@@ -139,6 +145,29 @@ $ kx state --all # lists all history entries and the current position
139
145
 
140
146
  Use `kx state <position>` to jump directly to any history entry, and `kx drop <position>` to remove one.
141
147
 
148
+ ## Configuration
149
+
150
+ `kx` reads `~/.kx/config.toml`; environment variables override file settings.
151
+
152
+ | Key | Env var | Default | Description |
153
+ | --- | --- | --- | --- |
154
+ | `max_history` | `KX_MAX_HISTORY` | `10` | Number of `kx get` results kept in history. |
155
+ | `shells` | `KX_SHELLS` (comma-separated) | `["bash", "sh"]` | Shell candidates for `kx exec`. |
156
+ | `no_color` | `KX_NO_COLOR` | `false` | Disable styled output (same as `--no-color`). |
157
+ | `theme` | `KX_THEME` | `"github-dark"` | Color theme for all output. |
158
+
159
+ Styled output is emitted only when stdout is a terminal — piped or redirected output is plain text, so `kx get pods | grep worker` stays clean. The [`NO_COLOR`](https://no-color.org/) convention is honored as well.
160
+
161
+ ### Themes
162
+
163
+ ```bash
164
+ kx theme # list available themes (indexed) with a preview of each
165
+ kx theme nord # persist a theme to ~/.kx/config.toml
166
+ kx theme 3 # same, selecting by index from the kx theme listing
167
+ ```
168
+
169
+ Prefab themes: `github-dark` (default), `dracula`, `nord`, `gruvbox`, `solarized-dark`, `catppuccin-mocha`, `tokyo-night`, `rose-pine`, `mono`, `light` (for light terminal backgrounds), and `plain` (no styling at all).
170
+
142
171
  ## Development
143
172
 
144
173
  ```bash
@@ -43,14 +43,17 @@ Fetches resources and assigns index numbers. Any extra flags (e.g. `-n <namespac
43
43
 
44
44
  ```
45
45
  $ kx get pods
46
- X NAME READY STATUS RESTARTS AGE
47
- 1 api-7d9f4b8c6-xkp2q 1/1 Running 0 2d
48
- 2 worker-6c8b5f7d9-mnt4r 1/1 Running 3 5h
49
- 3 postgres-0 1/1 Running 0 12d
46
+ Pods · default · 3 items
47
+ X NAME READY STATUS RESTARTS AGE
48
+ 1 api-7d9f4b8c6-xkp2q 1/1 Running 0 2d
49
+ 2 worker-6c8b5f7d9-mnt4r 1/1 Running 3 5h
50
+ 3 postgres-0 1/1 Running 0 12d
50
51
  ```
51
52
 
52
53
  All subsequent commands reference resources by their `X` index from the last `kx get`.
53
54
 
55
+ Global flags: `--no-color` disables styled output, `--version` prints the installed version, and `--help` on any command shows usage, examples, and aliases.
56
+
54
57
  ### Commands
55
58
 
56
59
  <!-- commands-table-start -->
@@ -69,7 +72,9 @@ All subsequent commands reference resources by their `X` index from the last `kx
69
72
  | `kx rollout <action> <index>` | Run a rollout action (status, restart, pause, resume, history, undo) on a Deployment, StatefulSet, or DaemonSet. |
70
73
  | `kx scale <index> <replicas>` | Scale an indexed Deployment, StatefulSet, or ReplicaSet to a given replica count. |
71
74
  | `kx port-forward <index> <port> [kubectl flags...]` | Forward a local port to an indexed resource (Pod, Deployment, ReplicaSet, StatefulSet, DaemonSet, Service). |
75
+ | `kx diagnostic <index>` | Run read-only health diagnostics on an indexed Deployment, StatefulSet, DaemonSet, or Pod; alias: kx diag. |
72
76
  | `kx namespace <index>` | Switch to an indexed namespace; alias: kx ns (run kx get namespaces first). |
77
+ | `kx theme [<name>]` | List available color themes or persist a choice by name or index. |
73
78
  | `kx state [<position>] [--all/-a]` | Show current state, jump to a history position, or list all entries with --all. |
74
79
  | `kx drop <position>` | Remove a history entry by position (shown in kx state --all). |
75
80
  | `kx back` | Navigate to the previous kx get result. |
@@ -108,7 +113,7 @@ kx delete 3
108
113
 
109
114
  ## State
110
115
 
111
- `kx` maintains a history of up to 10 `kx get` results in `~/.kx_state.json`. A cursor tracks your current position; index-based commands always resolve against the entry at the cursor.
116
+ `kx` maintains a history of up to 10 `kx get` results in `~/.kx/state.json`. A cursor tracks your current position; index-based commands always resolve against the entry at the cursor.
112
117
 
113
118
  ```
114
119
  $ kx get pods # saves a new entry, cursor advances
@@ -119,6 +124,29 @@ $ kx state --all # lists all history entries and the current position
119
124
 
120
125
  Use `kx state <position>` to jump directly to any history entry, and `kx drop <position>` to remove one.
121
126
 
127
+ ## Configuration
128
+
129
+ `kx` reads `~/.kx/config.toml`; environment variables override file settings.
130
+
131
+ | Key | Env var | Default | Description |
132
+ | --- | --- | --- | --- |
133
+ | `max_history` | `KX_MAX_HISTORY` | `10` | Number of `kx get` results kept in history. |
134
+ | `shells` | `KX_SHELLS` (comma-separated) | `["bash", "sh"]` | Shell candidates for `kx exec`. |
135
+ | `no_color` | `KX_NO_COLOR` | `false` | Disable styled output (same as `--no-color`). |
136
+ | `theme` | `KX_THEME` | `"github-dark"` | Color theme for all output. |
137
+
138
+ Styled output is emitted only when stdout is a terminal — piped or redirected output is plain text, so `kx get pods | grep worker` stays clean. The [`NO_COLOR`](https://no-color.org/) convention is honored as well.
139
+
140
+ ### Themes
141
+
142
+ ```bash
143
+ kx theme # list available themes (indexed) with a preview of each
144
+ kx theme nord # persist a theme to ~/.kx/config.toml
145
+ kx theme 3 # same, selecting by index from the kx theme listing
146
+ ```
147
+
148
+ Prefab themes: `github-dark` (default), `dracula`, `nord`, `gruvbox`, `solarized-dark`, `catppuccin-mocha`, `tokyo-night`, `rose-pine`, `mono`, `light` (for light terminal backgrounds), and `plain` (no styling at all).
149
+
122
150
  ## Development
123
151
 
124
152
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kx-cli"
3
- version = "0.0.5"
3
+ version = "0.0.6"
4
4
  description = "kubectl wrapper with index-based resource selection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -21,6 +21,7 @@ dev = [
21
21
  "ruff",
22
22
  "pre-commit",
23
23
  "pytest",
24
+ "pytest-cov",
24
25
  ]
25
26
 
26
27
  [project.scripts]
@@ -11,7 +11,8 @@ class DescribeCommand:
11
11
  self.state = state
12
12
  self.kubectl = kubectl
13
13
 
14
- def execute(self, index: int, extra_args: list[str] = []) -> None:
14
+ def execute(self, index: int, extra_args: list[str] | None = None) -> None:
15
+ extra_args = extra_args or []
15
16
  name, namespace, kind = self.state.fields(index)
16
17
  self.kubectl.run_interactive(
17
18
  ["describe", kind, name, "-n", namespace, *extra_args]
@@ -0,0 +1,195 @@
1
+ from kx.diagnostics import (
2
+ ContainerDiagnostic,
3
+ DiagnosticData,
4
+ DiagnosticReport,
5
+ DiagnosticsServiceProtocol,
6
+ Finding,
7
+ PodDiagnostic,
8
+ ReplicaHealth,
9
+ Severity,
10
+ )
11
+ from kx.kinds import Kind
12
+ from kx.state import StateServiceProtocol
13
+
14
+ _SUPPORTED_KINDS = {Kind.Deployment, Kind.StatefulSet, Kind.DaemonSet, Kind.Pod}
15
+ _RESTART_WARN_THRESHOLD = 5
16
+
17
+ _IMAGE_PULL_REASONS = {"ImagePullBackOff", "ErrImagePull", "InvalidImageName"}
18
+ _CONFIG_ERROR_REASONS = {"CreateContainerConfigError", "CreateContainerError"}
19
+
20
+
21
+ class DiagnosticCommand:
22
+ def __init__(
23
+ self,
24
+ state: StateServiceProtocol,
25
+ diagnostics: DiagnosticsServiceProtocol,
26
+ ):
27
+ self.state = state
28
+ self.diagnostics = diagnostics
29
+
30
+ def execute(self, index: int) -> DiagnosticReport:
31
+ name, namespace, kind = self.state.fields(index)
32
+ if kind not in _SUPPORTED_KINDS:
33
+ raise ValueError(f"diagnostic is not supported for '{kind}'.")
34
+ return build_report(self.diagnostics.gather(kind, name, namespace))
35
+
36
+
37
+ def build_report(data: DiagnosticData) -> DiagnosticReport:
38
+ findings: list[Finding] = []
39
+
40
+ if data.replicas is not None:
41
+ findings.extend(_replica_findings(data.replicas))
42
+ for pod in data.pods:
43
+ findings.extend(_pod_findings(pod))
44
+ findings.extend(_event_findings(data.warning_events))
45
+
46
+ # Prioritize: highest severity first, stable within a severity.
47
+ findings.sort(key=lambda f: f.severity, reverse=True)
48
+ verdict = max((f.severity for f in findings), default=Severity.OK)
49
+
50
+ return DiagnosticReport(
51
+ kind=data.kind,
52
+ name=data.name,
53
+ namespace=data.namespace,
54
+ verdict=verdict,
55
+ findings=findings,
56
+ pods=data.pods,
57
+ warning_events=data.warning_events,
58
+ )
59
+
60
+
61
+ def _replica_findings(replicas: ReplicaHealth) -> list[Finding]:
62
+ findings: list[Finding] = []
63
+ gen = replicas.generation
64
+ observed = replicas.observed_generation
65
+ if gen is not None and observed is not None and observed < gen:
66
+ findings.append(
67
+ Finding(
68
+ Severity.CRITICAL,
69
+ f"Rollout stalled: observed generation {observed} behind spec generation {gen}",
70
+ )
71
+ )
72
+ if replicas.ready < replicas.desired:
73
+ severity = (
74
+ Severity.CRITICAL
75
+ if replicas.ready == 0 and replicas.desired > 0
76
+ else Severity.WARNING
77
+ )
78
+ findings.append(
79
+ Finding(
80
+ severity,
81
+ f"Only {replicas.ready}/{replicas.desired} replicas ready",
82
+ )
83
+ )
84
+ if replicas.available < replicas.desired:
85
+ findings.append(
86
+ Finding(
87
+ Severity.WARNING,
88
+ f"{replicas.available}/{replicas.desired} replicas available",
89
+ )
90
+ )
91
+ if replicas.updated < replicas.desired:
92
+ findings.append(
93
+ Finding(
94
+ Severity.WARNING,
95
+ f"Rollout in progress: {replicas.updated}/{replicas.desired} replicas updated",
96
+ )
97
+ )
98
+ return findings
99
+
100
+
101
+ def _pod_findings(pod: PodDiagnostic) -> list[Finding]:
102
+ findings: list[Finding] = []
103
+
104
+ for container in pod.containers:
105
+ findings.extend(_container_findings(pod.name, container))
106
+
107
+ if pod.phase == "Pending" and not pod.scheduling.schedulable:
108
+ detail = pod.scheduling.message or pod.scheduling.reason or "unschedulable"
109
+ findings.append(
110
+ Finding(Severity.CRITICAL, f"Pod {pod.name} unschedulable: {detail}")
111
+ )
112
+ elif pod.phase == "Pending":
113
+ findings.append(Finding(Severity.WARNING, f"Pod {pod.name} pending"))
114
+ elif pod.phase == "Failed":
115
+ findings.append(Finding(Severity.CRITICAL, f"Pod {pod.name} failed"))
116
+ elif (
117
+ pod.phase == "Running"
118
+ and pod.ready_containers < pod.total_containers
119
+ and not any(c.waiting_reason for c in pod.containers)
120
+ ):
121
+ findings.append(
122
+ Finding(
123
+ Severity.WARNING,
124
+ f"Pod {pod.name}: {pod.ready_containers}/{pod.total_containers} containers ready",
125
+ )
126
+ )
127
+ return findings
128
+
129
+
130
+ def _container_findings(pod_name: str, container: ContainerDiagnostic) -> list[Finding]:
131
+ findings: list[Finding] = []
132
+ reason = container.waiting_reason
133
+
134
+ if reason == "CrashLoopBackOff":
135
+ findings.append(
136
+ Finding(
137
+ Severity.CRITICAL,
138
+ f"CrashLoopBackOff in pod {pod_name} ({container.restart_count} restarts)",
139
+ )
140
+ )
141
+ elif reason in _IMAGE_PULL_REASONS:
142
+ findings.append(
143
+ Finding(
144
+ Severity.CRITICAL,
145
+ f"Image pull failure ({reason}) in pod {pod_name}",
146
+ )
147
+ )
148
+ elif reason in _CONFIG_ERROR_REASONS:
149
+ detail = container.waiting_message or reason
150
+ findings.append(
151
+ Finding(
152
+ Severity.CRITICAL,
153
+ f"Container config error in pod {pod_name}: {detail}",
154
+ )
155
+ )
156
+ elif reason is not None:
157
+ findings.append(
158
+ Finding(
159
+ Severity.WARNING,
160
+ f"Container {container.name} in pod {pod_name} waiting: {reason}",
161
+ )
162
+ )
163
+
164
+ if "OOMKilled" in (container.terminated_reason, container.last_terminated_reason):
165
+ findings.append(Finding(Severity.CRITICAL, f"OOMKilled in pod {pod_name}"))
166
+ elif container.terminated_reason not in (None, "Completed") and container.exit_code:
167
+ findings.append(
168
+ Finding(
169
+ Severity.CRITICAL,
170
+ f"Container {container.name} in pod {pod_name} terminated: "
171
+ f"{container.terminated_reason} (exit {container.exit_code})",
172
+ )
173
+ )
174
+
175
+ if reason is None and container.restart_count >= _RESTART_WARN_THRESHOLD:
176
+ findings.append(
177
+ Finding(
178
+ Severity.WARNING,
179
+ f"Container {container.name} in pod {pod_name} "
180
+ f"restarted {container.restart_count} times",
181
+ )
182
+ )
183
+ return findings
184
+
185
+
186
+ def _event_findings(events) -> list[Finding]:
187
+ # The message is deliberately omitted: the WARNING EVENTS section renders it
188
+ # in full, so repeating it here only bloats the summary.
189
+ return [
190
+ Finding(
191
+ Severity.WARNING,
192
+ f"{event.reason} ×{event.count} on {event.kind}/{event.name}",
193
+ )
194
+ for event in events
195
+ ]
@@ -7,6 +7,7 @@ class EditCommand:
7
7
  self.state = state
8
8
  self.kubectl = kubectl
9
9
 
10
- def execute(self, index: int, extra_args: list[str] = []) -> None:
10
+ def execute(self, index: int, extra_args: list[str] | None = None) -> None:
11
+ extra_args = extra_args or []
11
12
  name, namespace, kind = self.state.fields(index)
12
13
  self.kubectl.run_interactive(["edit", kind, name, "-n", namespace, *extra_args])
@@ -17,8 +17,9 @@ class ExecCommand:
17
17
  self.shells = shells
18
18
 
19
19
  def execute(
20
- self, index: int, cmd: list[str] | None, extra_args: list[str] = []
20
+ self, index: int, cmd: list[str] | None, extra_args: list[str] | None = None
21
21
  ) -> None:
22
+ extra_args = extra_args or []
22
23
  name, namespace, kind = self.state.fields(index)
23
24
  if kind != Kind.Pod:
24
25
  raise ValueError("exec is only supported for pods.")
@@ -28,8 +28,9 @@ class GetCommand:
28
28
  self,
29
29
  resource: str,
30
30
  filter_term: str | None = None,
31
- extra_args: list[str] = [],
31
+ extra_args: list[str] | None = None,
32
32
  ) -> str:
33
+ extra_args = extra_args or []
33
34
  output = self.kubectl.run(["get", resource, *extra_args])
34
35
  if filter_term:
35
36
  output = self.index.filter(output, filter_term)
@@ -12,7 +12,8 @@ class LogsCommand:
12
12
  self.state = state
13
13
  self.kubectl = kubectl
14
14
 
15
- def execute(self, index: int, extra_args: list[str] = []) -> None:
15
+ def execute(self, index: int, extra_args: list[str] | None = None) -> None:
16
+ extra_args = extra_args or []
16
17
  name, namespace, kind = self.state.fields(index)
17
18
  if kind == Kind.Pod:
18
19
  self.kubectl.run_interactive(["logs", name, "-n", namespace, *extra_args])
@@ -17,7 +17,10 @@ class PortForwardCommand:
17
17
  self.kubectl = kubectl
18
18
  self.state = state
19
19
 
20
- def execute(self, index: int, port: str, extra_args: list[str] = []) -> None:
20
+ def execute(
21
+ self, index: int, port: str, extra_args: list[str] | None = None
22
+ ) -> None:
23
+ extra_args = extra_args or []
21
24
  name, namespace, kind = self.state.fields(index)
22
25
  if kind not in _SUPPORTED_KINDS:
23
26
  raise ValueError(f"port-forward is not supported for '{kind}'.")
@@ -0,0 +1,23 @@
1
+ from typing import Callable
2
+
3
+ from kx.themes import THEMES
4
+
5
+
6
+ class ThemeCommand:
7
+ def __init__(self, save: Callable[[str], None]):
8
+ self.save = save
9
+
10
+ def execute(self, name: str) -> str:
11
+ if name.isdigit():
12
+ names = list(THEMES)
13
+ position = int(name)
14
+ if not 1 <= position <= len(names):
15
+ raise ValueError(
16
+ f"Theme index {position} is out of range — {len(names)} themes "
17
+ f"(run 'kx theme' to list)."
18
+ )
19
+ name = names[position - 1]
20
+ if name not in THEMES:
21
+ raise ValueError(f"Unknown theme '{name}'. Run 'kx theme' to list themes.")
22
+ self.save(name)
23
+ return f"Theme set to '{name}'"
@@ -0,0 +1,82 @@
1
+ import os
2
+ import re
3
+ import tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from kx.themes import DEFAULT_THEME, THEMES
8
+
9
+ _CONFIG_FILE = Path.home() / ".kx" / "config.toml"
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Config:
14
+ max_history: int = 10
15
+ shells: tuple[str, ...] = ("bash", "sh")
16
+ no_color: bool = False
17
+ theme: str = DEFAULT_THEME
18
+
19
+
20
+ def load_config() -> Config:
21
+ kwargs: dict = {}
22
+
23
+ if _CONFIG_FILE.exists():
24
+ try:
25
+ with open(_CONFIG_FILE, "rb") as f:
26
+ data = tomllib.load(f)
27
+ except tomllib.TOMLDecodeError as e:
28
+ raise SystemExit(f"kx: error reading {_CONFIG_FILE}: {e}")
29
+
30
+ if "max_history" in data:
31
+ kwargs["max_history"] = data["max_history"]
32
+ if "shells" in data:
33
+ kwargs["shells"] = tuple(data["shells"])
34
+ if "no_color" in data:
35
+ kwargs["no_color"] = data["no_color"]
36
+ if "theme" in data:
37
+ kwargs["theme"] = data["theme"]
38
+
39
+ if "KX_MAX_HISTORY" in os.environ:
40
+ try:
41
+ kwargs["max_history"] = int(os.environ["KX_MAX_HISTORY"])
42
+ except ValueError:
43
+ raise SystemExit("kx: KX_MAX_HISTORY must be an integer")
44
+
45
+ if "KX_SHELLS" in os.environ:
46
+ kwargs["shells"] = tuple(os.environ["KX_SHELLS"].split(","))
47
+
48
+ if "KX_NO_COLOR" in os.environ:
49
+ value = os.environ["KX_NO_COLOR"].lower()
50
+ kwargs["no_color"] = value in {"1", "true", "yes", "on"}
51
+
52
+ if "KX_THEME" in os.environ:
53
+ kwargs["theme"] = os.environ["KX_THEME"]
54
+
55
+ if "theme" in kwargs and kwargs["theme"] not in THEMES:
56
+ raise SystemExit(
57
+ f"kx: unknown theme '{kwargs['theme']}' (run kx theme to list themes)"
58
+ )
59
+
60
+ return Config(**kwargs)
61
+
62
+
63
+ def save_theme(name: str) -> None:
64
+ """Persist the theme choice to the config file.
65
+
66
+ Rewrites only the `theme = ...` line (or appends one) instead of
67
+ round-tripping the TOML, so user comments and formatting survive. Safe
68
+ because the config schema is flat: there are no tables a `theme` key
69
+ could belong to.
70
+ """
71
+ line = f'theme = "{name}"'
72
+ if _CONFIG_FILE.exists():
73
+ text = _CONFIG_FILE.read_text()
74
+ new, count = re.subn(r"(?m)^theme\s*=.*$", line, text, count=1)
75
+ if count == 0:
76
+ if text and not text.endswith("\n"):
77
+ text += "\n"
78
+ new = text + line + "\n"
79
+ _CONFIG_FILE.write_text(new)
80
+ else:
81
+ _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
82
+ _CONFIG_FILE.write_text(line + "\n")