kx-cli 0.0.1__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.
kx/__init__.py ADDED
File without changes
kx/events.py ADDED
@@ -0,0 +1,43 @@
1
+ from kubernetes import client
2
+ from kx.k8s import load_k8s
3
+
4
+ _KIND_MAP = {
5
+ "pod": "Pod", "pods": "Pod",
6
+ "deployment": "Deployment", "deployments": "Deployment", "deploy": "Deployment",
7
+ "replicaset": "ReplicaSet", "replicasets": "ReplicaSet", "rs": "ReplicaSet",
8
+ "statefulset": "StatefulSet", "statefulsets": "StatefulSet", "sts": "StatefulSet",
9
+ "daemonset": "DaemonSet", "daemonsets": "DaemonSet", "ds": "DaemonSet",
10
+ "hpa": "HorizontalPodAutoscaler",
11
+ "horizontalpodautoscaler": "HorizontalPodAutoscaler",
12
+ "horizontalpodautoscalers": "HorizontalPodAutoscaler",
13
+ "service": "Service", "services": "Service", "svc": "Service",
14
+ "ingress": "Ingress", "ingresses": "Ingress",
15
+ "configmap": "ConfigMap", "configmaps": "ConfigMap", "cm": "ConfigMap",
16
+ "secret": "Secret", "secrets": "Secret",
17
+ "job": "Job", "jobs": "Job",
18
+ "cronjob": "CronJob", "cronjobs": "CronJob",
19
+ "pvc": "PersistentVolumeClaim",
20
+ "persistentvolumeclaim": "PersistentVolumeClaim",
21
+ "persistentvolumeclaims": "PersistentVolumeClaim",
22
+ "node": "Node", "nodes": "Node",
23
+ "namespace": "Namespace", "namespaces": "Namespace",
24
+ }
25
+
26
+
27
+ def normalize_kind(resource_type: str) -> str:
28
+ return _KIND_MAP.get(resource_type.lower(), resource_type)
29
+
30
+
31
+ def get_events(namespace: str):
32
+ load_k8s()
33
+ v1 = client.CoreV1Api()
34
+
35
+ return v1.list_namespaced_event(namespace=namespace).items
36
+
37
+ def filter_events(events, name: str, kind: str):
38
+ normalized = normalize_kind(kind)
39
+ return [
40
+ e for e in events
41
+ if e.involved_object.name == name
42
+ and e.involved_object.kind == normalized
43
+ ]
kx/graph.py ADDED
@@ -0,0 +1,89 @@
1
+ from kubernetes import client
2
+ from rich.tree import Tree
3
+
4
+ from kx.k8s import load_k8s
5
+
6
+
7
+ def build_tree(kind: str, name: str, namespace: str) -> Tree:
8
+ load_k8s()
9
+ root = Tree(f"[bold]{kind}/{name}[/bold]")
10
+
11
+ apps = client.AppsV1Api()
12
+ core = client.CoreV1Api()
13
+ batch = client.BatchV1Api()
14
+
15
+ pods = core.list_namespaced_pod(namespace).items
16
+
17
+ if kind == "Deployment":
18
+ _tree_deployment(name, namespace, root, apps, pods)
19
+ elif kind == "ReplicaSet":
20
+ _tree_replica_set(name, namespace, root, apps, pods)
21
+ elif kind == "StatefulSet":
22
+ _tree_stateful_set(name, namespace, root, apps, pods)
23
+ elif kind == "DaemonSet":
24
+ _tree_daemon_set(name, namespace, root, apps, pods)
25
+ elif kind == "CronJob":
26
+ _tree_cron_job(name, namespace, root, batch, pods)
27
+ elif kind == "Pod":
28
+ pod = core.read_namespaced_pod(name, namespace)
29
+ _add_containers(pod, root)
30
+ else:
31
+ root.add(f"[dim](no ownership graph for {kind})[/dim]")
32
+
33
+ return root
34
+
35
+
36
+ def _tree_deployment(name, namespace, node, apps, pods):
37
+ deploy = apps.read_namespaced_deployment(name, namespace)
38
+ uid = deploy.metadata.uid
39
+ replica_sets = [
40
+ rs for rs in apps.list_namespaced_replica_set(namespace).items
41
+ if _owned_by(rs, uid)
42
+ ]
43
+ for rs in replica_sets:
44
+ rs_node = node.add(f"[green]rs/{rs.metadata.name}[/green]")
45
+ _add_pods_for_owner(rs.metadata.uid, pods, rs_node)
46
+
47
+
48
+ def _tree_replica_set(name, namespace, node, apps, pods):
49
+ rs = apps.read_namespaced_replica_set(name, namespace)
50
+ _add_pods_for_owner(rs.metadata.uid, pods, node)
51
+
52
+
53
+ def _tree_stateful_set(name, namespace, node, apps, pods):
54
+ sts = apps.read_namespaced_stateful_set(name, namespace)
55
+ _add_pods_for_owner(sts.metadata.uid, pods, node)
56
+
57
+
58
+ def _tree_daemon_set(name, namespace, node, apps, pods):
59
+ ds = apps.read_namespaced_daemon_set(name, namespace)
60
+ _add_pods_for_owner(ds.metadata.uid, pods, node)
61
+
62
+
63
+ def _tree_cron_job(name, namespace, node, batch, pods):
64
+ cj = batch.read_namespaced_cron_job(name, namespace)
65
+ uid = cj.metadata.uid
66
+ jobs = [
67
+ j for j in batch.list_namespaced_job(namespace).items
68
+ if _owned_by(j, uid)
69
+ ]
70
+ for job in jobs:
71
+ job_node = node.add(f"[green]job/{job.metadata.name}[/green]")
72
+ _add_pods_for_owner(job.metadata.uid, pods, job_node)
73
+
74
+
75
+ def _add_pods_for_owner(owner_uid, pods, parent_node):
76
+ owned = [p for p in pods if _owned_by(p, owner_uid)]
77
+ for pod in owned:
78
+ pod_node = parent_node.add(f"[blue]pod/{pod.metadata.name}[/blue]")
79
+ _add_containers(pod, pod_node)
80
+
81
+
82
+ def _add_containers(pod, parent_node):
83
+ for container in pod.spec.containers:
84
+ parent_node.add(f"[cyan]container: {container.name}[/cyan]")
85
+
86
+
87
+ def _owned_by(resource, uid: str) -> bool:
88
+ refs = resource.metadata.owner_references or []
89
+ return any(ref.uid == uid for ref in refs)
kx/index.py ADDED
@@ -0,0 +1,56 @@
1
+ import typer
2
+
3
+
4
+ def resolve_index(state, index: int) -> str:
5
+ try:
6
+ return state.names[index - 1]
7
+ except IndexError:
8
+ typer.echo("Invalid index")
9
+ raise typer.Exit(1)
10
+
11
+ def add_indexes(output: str) -> tuple[str, list[str]]:
12
+ import re
13
+
14
+ lines = output.splitlines()
15
+ if not lines:
16
+ return output, []
17
+
18
+ header = lines[0]
19
+ rows = lines[1:]
20
+
21
+ # compute column boundaries from header
22
+ spans = [(m.start(), m.end()) for m in re.finditer(r"\S+\s*", header)]
23
+
24
+ headers = [header[s:e].strip() for s, e in spans]
25
+ name_idx = headers.index("NAME")
26
+
27
+ names = []
28
+
29
+ parsed_rows = []
30
+ for r in rows:
31
+ if not r.strip():
32
+ continue
33
+
34
+ cols = [r[s:e].strip() for s, e in spans]
35
+
36
+ if len(cols) <= name_idx:
37
+ continue
38
+
39
+ names.append(cols[name_idx])
40
+ parsed_rows.append(cols)
41
+
42
+ # add index column (X)
43
+ headers = ["X"] + headers
44
+ parsed_rows = [[str(i + 1)] + r for i, r in enumerate(parsed_rows)]
45
+
46
+ # compute widths
47
+ cols = list(zip(headers, *parsed_rows))
48
+ widths = [max(len(str(cell)) for cell in col) for col in cols]
49
+
50
+ def fmt(row):
51
+ return " ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row))
52
+
53
+ out = [fmt(headers)]
54
+ out += [fmt(r) for r in parsed_rows]
55
+
56
+ return "\n".join(out), names
kx/k8s.py ADDED
@@ -0,0 +1,7 @@
1
+ from kubernetes import client, config
2
+
3
+ def load_k8s():
4
+ try:
5
+ config.load_kube_config()
6
+ except Exception:
7
+ config.load_incluster_config()
kx/kubectl.py ADDED
@@ -0,0 +1,16 @@
1
+ import subprocess
2
+
3
+
4
+ def run_kubectl(args: list[str]) -> str:
5
+ result = subprocess.run(
6
+ ["kubectl", *args],
7
+ capture_output=True,
8
+ text=True,
9
+ check=True,
10
+ )
11
+ return result.stdout
12
+
13
+
14
+ def run_kubectl_interactive(args: list[str]) -> int:
15
+ result = subprocess.run(["kubectl", *args])
16
+ return result.returncode
kx/main.py ADDED
@@ -0,0 +1,166 @@
1
+ import subprocess
2
+
3
+ import typer
4
+
5
+ from kx.kubectl import run_kubectl, run_kubectl_interactive
6
+ from kx.index import add_indexes, resolve_index
7
+ from kx.state import KxState, save_state, load_state
8
+
9
+ app = typer.Typer(help="kx - kubectl extended with indexing")
10
+
11
+
12
+ def _get_current_namespace() -> str:
13
+ result = subprocess.run(
14
+ ["kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}"],
15
+ capture_output=True,
16
+ text=True,
17
+ check=True,
18
+ )
19
+ ns = result.stdout.strip()
20
+ return ns if ns else "default"
21
+
22
+
23
+ def _state_fields(index: int) -> tuple[str, str, str]:
24
+ state = load_state()
25
+ name = resolve_index(state, index)
26
+ return name, state.namespace, state.resource_type
27
+
28
+
29
+ @app.command()
30
+ def get(
31
+ resource: str,
32
+ namespace: str = typer.Option(None, "-n", help="Kubernetes namespace"),
33
+ ):
34
+ """List resources and assign index numbers for use with other commands."""
35
+ if namespace is None:
36
+ namespace = _get_current_namespace()
37
+
38
+ output = run_kubectl(["get", resource, "-n", namespace])
39
+ indexed_output, names = add_indexes(output)
40
+ typer.echo(indexed_output)
41
+ save_state(KxState(resource_type=resource, names=names, namespace=namespace))
42
+
43
+
44
+ @app.command()
45
+ def describe(
46
+ index: int,
47
+ view: str = typer.Option("full", help="Output view: full or events"),
48
+ ):
49
+ """Show full kubectl describe output for an indexed resource."""
50
+ name, namespace, kind = _state_fields(index)
51
+
52
+ if view == "events":
53
+ from kx.events import get_events, filter_events
54
+
55
+ all_events = get_events(namespace)
56
+ events = filter_events(all_events, name, kind)
57
+
58
+ if not events:
59
+ typer.echo("No events found")
60
+ return
61
+
62
+ for e in events:
63
+ obj = e.involved_object
64
+ typer.echo(
65
+ f"{e.type:8} {e.reason:30} "
66
+ f"{obj.kind:10} {e.metadata.creation_timestamp} "
67
+ f"{e.message}"
68
+ )
69
+ return
70
+
71
+ subprocess.run(["kubectl", "describe", kind, name, "-n", namespace])
72
+
73
+
74
+ @app.command()
75
+ def events(index: int):
76
+ """Show Kubernetes events for an indexed resource."""
77
+ from kx.events import get_events, filter_events
78
+
79
+ name, namespace, kind = _state_fields(index)
80
+ all_events = get_events(namespace)
81
+ filtered = filter_events(all_events, name, kind)
82
+
83
+ if not filtered:
84
+ typer.echo("No events found")
85
+ return
86
+
87
+ for e in filtered:
88
+ obj = e.involved_object
89
+ typer.echo(
90
+ f"{e.type:8} {e.reason:30} "
91
+ f"{obj.kind:10} {e.metadata.creation_timestamp} "
92
+ f"{e.message}"
93
+ )
94
+
95
+
96
+ @app.command()
97
+ def logs(index: int):
98
+ """Stream logs for an indexed pod."""
99
+ name, namespace, kind = _state_fields(index)
100
+
101
+ if kind != "pod":
102
+ typer.echo("Logs are only supported on pods.")
103
+ raise typer.Exit(1)
104
+
105
+ args = ["logs", name, "-n", namespace]
106
+ typer.echo(run_kubectl(args))
107
+
108
+
109
+ @app.command()
110
+ def yaml(index: int):
111
+ """Print the raw YAML manifest for an indexed resource."""
112
+ name, namespace, kind = _state_fields(index)
113
+ typer.echo(run_kubectl(["get", kind, name, "-n", namespace, "-o", "yaml"]))
114
+
115
+
116
+ @app.command()
117
+ def delete(
118
+ index: int,
119
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
120
+ ):
121
+ """Delete an indexed resource (prompts for confirmation unless --yes)."""
122
+ name, namespace, kind = _state_fields(index)
123
+ if not yes:
124
+ typer.confirm(f"Delete {kind}/{name} in {namespace}?", abort=True)
125
+ run_kubectl(["delete", kind, name, "-n", namespace])
126
+ typer.echo(f"Deleted {kind}/{name}")
127
+
128
+
129
+ @app.command()
130
+ def edit(index: int):
131
+ """Open an indexed resource in your editor via kubectl edit."""
132
+ name, namespace, kind = _state_fields(index)
133
+ run_kubectl_interactive(["edit", kind, name, "-n", namespace])
134
+
135
+
136
+ @app.command(name="exec")
137
+ def exec_cmd(
138
+ index: int,
139
+ cmd: list[str] = typer.Argument(default=None, help="Command to run (default: bash with sh fallback)"),
140
+ ):
141
+ """Open an interactive shell in an indexed pod (bash, falling back to sh)."""
142
+ name, namespace, kind = _state_fields(index)
143
+ if kind.lower() not in ("pod", "pods"):
144
+ typer.echo("exec is only supported for pods.")
145
+ raise typer.Exit(1)
146
+ if cmd:
147
+ run_kubectl_interactive(["exec", "-it", name, "-n", namespace, "--", *cmd])
148
+ else:
149
+ rc = run_kubectl_interactive(["exec", "-it", name, "-n", namespace, "--", "bash"])
150
+ if rc != 0:
151
+ run_kubectl_interactive(["exec", "-it", name, "-n", namespace, "--", "sh"])
152
+
153
+
154
+ @app.command()
155
+ def tree(index: int):
156
+ """Show the ownership graph for an indexed resource (deployments, statefulsets, etc.)."""
157
+ from kx.events import normalize_kind
158
+ from kx.graph import build_tree
159
+ from rich.console import Console
160
+
161
+ name, namespace, kind = _state_fields(index)
162
+ Console().print(build_tree(normalize_kind(kind), name, namespace))
163
+
164
+
165
+ if __name__ == "__main__":
166
+ app()
kx/state.py ADDED
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass, asdict
2
+ import json
3
+ from pathlib import Path
4
+
5
+ STATE_FILE = Path.home() / ".kx_state.json"
6
+
7
+
8
+ @dataclass
9
+ class KxState:
10
+ resource_type: str
11
+ names: list[str]
12
+ namespace: str = "default"
13
+
14
+
15
+ def save_state(state: KxState) -> None:
16
+ STATE_FILE.write_text(json.dumps(asdict(state)))
17
+
18
+
19
+ def load_state() -> KxState:
20
+ if not STATE_FILE.exists():
21
+ raise RuntimeError("No state found. Run `kx get <resource>` first.")
22
+ data = json.loads(STATE_FILE.read_text())
23
+ return KxState(**data)
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: kx-cli
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: typer
6
+ Requires-Dist: kubernetes
7
+ Requires-Dist: rich
@@ -0,0 +1,13 @@
1
+ kx/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ kx/events.py,sha256=yUBgELf7NhMTlLK86sRwN3QBPmAhEiumRnqmP0GV-Ag,1627
3
+ kx/graph.py,sha256=jJy84CUY-x5r-1Lrz10I1w7DYRA2L54ycgaeJPo0bW0,2923
4
+ kx/index.py,sha256=OofJB2sW53QmV5VZTSaVZ3p2-WWWj3EddDiOWF16LsU,1344
5
+ kx/k8s.py,sha256=Q1dlnlFk-xRE7ZoViZdtZZEI76UUVHXoT9eARxbJqe0,158
6
+ kx/kubectl.py,sha256=mq09eQgOPNiyqO564byiSAeNiVY0CLTDageqa47taNk,348
7
+ kx/main.py,sha256=rIxN1uqs8fJYX_D4vp6c7vRwQ4vyz0VL9l1DPotXLNM,4998
8
+ kx/state.py,sha256=W9puXcyiQCHSWp5JRhqB3puc6ktqjwTaegJRG-YBOT4,535
9
+ kx_cli-0.0.1.dist-info/METADATA,sha256=g6KwISxwWno01JtEXyQ1khmWlqoIJi7ZaxIUaY6buBU,141
10
+ kx_cli-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ kx_cli-0.0.1.dist-info/entry_points.txt,sha256=7FKIfh_28wCHnIO0TFsW70g2JqsoRsSTjIK161QYQwQ,35
12
+ kx_cli-0.0.1.dist-info/top_level.txt,sha256=HMijR3s083ws0pe6Fso-IU3si7RWxFUEY7BCKg15G5E,3
13
+ kx_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kx = kx.main:app
@@ -0,0 +1 @@
1
+ kx