kube-query 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kube_query/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """kube-query - read-only Kubernetes query CLI for humans and AI agents."""
2
+
3
+ __version__ = "0.1.0"
kube_query/access.py ADDED
@@ -0,0 +1,85 @@
1
+ """Object-level access: list/get any resource by its ApiResource, read pod logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ from .config import Ctx
8
+ from .discovery import ApiResource
9
+ from .errors import KqApiError, KqError
10
+ from .http import get_json, get_text
11
+
12
+
13
+ def resource_path(entry: ApiResource, namespace: str | None = None, name: str | None = None) -> str:
14
+ if entry.group:
15
+ base = f"/apis/{quote(entry.api_version, safe='/')}"
16
+ else:
17
+ base = f"/api/{entry.version}"
18
+ if entry.namespaced and namespace:
19
+ base += f"/namespaces/{quote(namespace, safe='')}"
20
+ base += f"/{entry.plural}"
21
+ if name:
22
+ base += f"/{quote(name, safe='')}"
23
+ return base
24
+
25
+
26
+ def list_objects(
27
+ ctx: Ctx,
28
+ entry: ApiResource,
29
+ *,
30
+ namespace: str | None = None,
31
+ label_selector: str | None = None,
32
+ field_selector: str | None = None,
33
+ ) -> list[dict]:
34
+ """List objects. ``namespace=None`` on a namespaced resource lists all namespaces."""
35
+ if not entry.namespaced and namespace:
36
+ namespace = None
37
+ params = {"labelSelector": label_selector, "fieldSelector": field_selector}
38
+ data = get_json(ctx.api_client, resource_path(entry, namespace), params)
39
+ items = data.get("items")
40
+ if not isinstance(items, list):
41
+ raise KqError(f"unexpected response while listing {entry.plural}")
42
+ return items
43
+
44
+
45
+ def get_object(ctx: Ctx, entry: ApiResource, name: str, *, namespace: str | None = None) -> dict:
46
+ if entry.namespaced and not namespace:
47
+ raise KqError(f"internal: namespace required to fetch a namespaced {entry.kind}")
48
+ try:
49
+ return get_json(ctx.api_client, resource_path(entry, namespace, name))
50
+ except KqApiError as e:
51
+ if e.status == 404:
52
+ where = f" in namespace '{namespace}'" if entry.namespaced else ""
53
+ scope = f" -n {namespace}" if entry.namespaced else ""
54
+ alt = " (or -A for all namespaces)" if entry.namespaced else ""
55
+ raise KqError(
56
+ f"{entry.kind} '{name}' not found{where}.\n"
57
+ f" Hint: list candidates with 'kq get {entry.plural}{scope}'{alt}."
58
+ ) from None
59
+ raise
60
+
61
+
62
+ def pod_logs(
63
+ ctx: Ctx,
64
+ namespace: str,
65
+ pod: str,
66
+ *,
67
+ container: str | None = None,
68
+ tail: int | None = None,
69
+ previous: bool = False,
70
+ since_seconds: int | None = None,
71
+ timestamps: bool = False,
72
+ ) -> str:
73
+ path = f"/api/v1/namespaces/{quote(namespace, safe='')}/pods/{quote(pod, safe='')}/log"
74
+ params = {
75
+ "container": container,
76
+ "tailLines": tail,
77
+ "previous": "true" if previous else None,
78
+ "sinceSeconds": since_seconds,
79
+ "timestamps": "true" if timestamps else None,
80
+ }
81
+ return get_text(ctx.api_client, path, params)
82
+
83
+
84
+ def server_version(ctx: Ctx) -> dict:
85
+ return get_json(ctx.api_client, "/version")
kube_query/cleanup.py ADDED
@@ -0,0 +1,115 @@
1
+ """Output compaction: strip agent-irrelevant noise from Kubernetes objects.
2
+
3
+ Verbosity levels::
4
+
5
+ 0 (default) drop managedFields, uid, resourceVersion, generation,
6
+ selfLink, noisy annotations, and all empty values
7
+ 1 (-v) drop managedFields and noisy annotations only
8
+ 2 (-vv) raw object, nothing is stripped
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+
15
+ NOISY_META = ("managedFields", "selfLink", "resourceVersion", "uid", "generation")
16
+ NOISY_ANNOTATIONS = (
17
+ "kubectl.kubernetes.io/last-applied-configuration",
18
+ "deployment.kubernetes.io/revision",
19
+ "deprecated.daemonset.template.generation",
20
+ )
21
+ _EMPTY = ("", {}, [], None)
22
+
23
+
24
+ def compact(obj: dict | list, verbose: int = 0) -> dict | list:
25
+ if verbose >= 2:
26
+ return obj
27
+ obj = _strip_noisy(obj)
28
+ if verbose == 0:
29
+ obj = _drop_empty(obj)
30
+ return obj
31
+
32
+
33
+ def _strip_noisy(node):
34
+ if isinstance(node, dict):
35
+ out = {}
36
+ for k, v in node.items():
37
+ if k == "metadata" and isinstance(v, dict):
38
+ v = {mk: mv for mk, mv in v.items() if mk != "managedFields"}
39
+ ann = v.get("annotations")
40
+ if isinstance(ann, dict):
41
+ ann = {a: b for a, b in ann.items() if a not in NOISY_ANNOTATIONS}
42
+ if ann:
43
+ v["annotations"] = ann
44
+ else:
45
+ v.pop("annotations", None)
46
+ out[k] = _strip_noisy(v)
47
+ continue
48
+ out[k] = _strip_noisy(v)
49
+ return out
50
+ if isinstance(node, list):
51
+ return [_strip_noisy(x) for x in node]
52
+ return node
53
+
54
+
55
+ def _drop_empty(node):
56
+ if isinstance(node, dict):
57
+ out = {}
58
+ for k, v in node.items():
59
+ if k == "metadata" and isinstance(v, dict):
60
+ v = {mk: mv for mk, mv in v.items() if mk not in NOISY_META}
61
+ v = _drop_empty(v)
62
+ if v in _EMPTY:
63
+ continue
64
+ out[k] = v
65
+ return out
66
+ if isinstance(node, list):
67
+ return [_drop_empty(x) for x in node]
68
+ return node
69
+
70
+
71
+ # -- display helpers for ConfigMaps / Secrets ------------------------------
72
+
73
+
74
+ def cap_map_values(obj: dict, limit: int = 2000) -> dict:
75
+ """Cap long string values in data maps (ConfigMap display) in place."""
76
+ for key in ("data", "stringData", "binaryData"):
77
+ m = obj.get(key)
78
+ if not isinstance(m, dict):
79
+ continue
80
+ for k, v in list(m.items()):
81
+ if isinstance(v, str) and len(v) > limit:
82
+ m[k] = v[:limit] + f" ...[truncated {len(v) - limit} of {len(v)} chars; -v to see full]"
83
+ return obj
84
+
85
+
86
+ def redact_map_values(obj: dict) -> dict:
87
+ """Hide secret values in place; keys and sizes stay visible."""
88
+ for key in ("data", "stringData", "binaryData"):
89
+ m = obj.get(key)
90
+ if not isinstance(m, dict):
91
+ continue
92
+ obj[key] = {k: (f"<{len(v)} chars hidden -- use --reveal>" if isinstance(v, str) else v) for k, v in m.items()}
93
+ return obj
94
+
95
+
96
+ def reveal_map_values(obj: dict) -> dict:
97
+ """Base64-decode secret data values in place (binary values stay encoded)."""
98
+ m = obj.get("data")
99
+ if isinstance(m, dict):
100
+ out = {}
101
+ for k, v in m.items():
102
+ if not isinstance(v, str):
103
+ out[k] = v
104
+ continue
105
+ try:
106
+ raw = base64.b64decode(v)
107
+ except Exception:
108
+ out[k] = v
109
+ continue
110
+ try:
111
+ out[k] = raw.decode("utf-8")
112
+ except UnicodeDecodeError:
113
+ out[k] = f"<{len(raw)} bytes binary; base64: {v[:100]}>"
114
+ obj["data"] = out
115
+ return obj
kube_query/cli.py ADDED
@@ -0,0 +1,81 @@
1
+ """kq — read-only Kubernetes queries for humans and AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from . import __version__
8
+ from .cli_kit import KqGroup
9
+ from .commands import register
10
+ from .config import Settings
11
+ from .output import FORMATS
12
+
13
+ EPILOG = """\
14
+ \b
15
+ USAGE PATTERNS
16
+ kq overview cluster snapshot: problems first
17
+ kq p l [-A|-n NS] [REGEX] list pods (d=deploy, n=ns, no=node,
18
+ kq p NAME / p desc NAME inspect sec=secret, cm, svc, ev)
19
+ kq logs NAME [-c C] [--tail N] pod logs kq tree deploy/NAME
20
+ kq get RESOURCE[/NAME] any resource incl. CRDs; kq api lists all
21
+ kq helm ls -A / kq eso -A helm releases / ExternalSecret status
22
+
23
+ \b
24
+ OPTIONS
25
+ -o yaml|json|rich|md|tree output (default: compact yaml)
26
+ -n NS | -A | -l SELECTOR | -f RX scope: namespace / all / labels / name regex
27
+ -v (system metadata) -vv (raw) verbosity
28
+ --ctx NAME / --kubeconfig PATH cluster selection
29
+
30
+ \b
31
+ ROLE
32
+ kq CAPTURES cluster state for diagnosis; fixing belongs to other tools
33
+ (kubectl apply, gitops, helm). kq only sends GET requests - it can
34
+ never create, update or delete anything.
35
+ """
36
+
37
+
38
+ @click.group(
39
+ cls=KqGroup,
40
+ context_settings={"help_option_names": ["-h", "--help"]},
41
+ )
42
+ @click.version_option(__version__, "--version", prog_name="kube-query")
43
+ @click.option("--ctx", "--context", "context", default=None, help="Kubeconfig context to use.")
44
+ @click.option("--kubeconfig", default=None, type=click.Path(exists=True), help="Path to a kubeconfig file.")
45
+ @click.option("-n", "--ns", "--namespace", "namespace", default=None, help="Default namespace for this invocation.")
46
+ @click.option(
47
+ "-A", "--all-namespaces", "all_namespaces", is_flag=True, default=False, help="All namespaces (where applicable)."
48
+ )
49
+ @click.option(
50
+ "-o",
51
+ "--output",
52
+ "output_fmt",
53
+ type=click.Choice(FORMATS),
54
+ default=None,
55
+ help="Output format: yaml, json, rich, md, tree. [env: KQ_OUTPUT]",
56
+ )
57
+ @click.option("-v", "--verbose", count=True, help="Verbosity: -v keep system metadata, -vv raw object.")
58
+ @click.pass_context
59
+ def main(ctx, context, kubeconfig, namespace, all_namespaces, output_fmt, verbose):
60
+ """kq - read-only Kubernetes queries for AI agents and humans.
61
+
62
+ Compact yaml by default; rich tables, markdown, json and ownership
63
+ trees on request. Built-ins, shortnames, CRDs, Helm releases and
64
+ External Secrets Operator. Start with: kq overview
65
+ """
66
+ ctx.obj = Settings(
67
+ context=context,
68
+ kubeconfig=kubeconfig,
69
+ namespace=namespace,
70
+ output=output_fmt,
71
+ verbose=verbose,
72
+ all_namespaces=all_namespaces,
73
+ )
74
+
75
+
76
+ main.epilog = EPILOG
77
+ register(main)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
kube_query/cli_kit.py ADDED
@@ -0,0 +1,175 @@
1
+ """CLI building blocks: alias-hiding groups, resource groups, common options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from .output import FORMATS
8
+
9
+
10
+ class KqGroup(click.Group):
11
+ """A click Group that hides registered aliases from help listings."""
12
+
13
+ def __init__(self, *args, **kwargs) -> None:
14
+ super().__init__(*args, **kwargs)
15
+ self._aliases: set[str] = set()
16
+
17
+ def add_command(self, cmd, name=None, *, alias: bool = False) -> None:
18
+ super().add_command(cmd, name)
19
+ if alias:
20
+ self._aliases.add(name or cmd.name)
21
+
22
+ def list_commands(self, ctx) -> list[str]:
23
+ return [n for n in self.commands if n not in self._aliases]
24
+
25
+
26
+ class ResourceGroup(KqGroup):
27
+ """A resource group:
28
+
29
+ - ``kq pod`` -> runs the default command (ls)
30
+ - ``kq pod nginx-123`` -> unknown token dispatches to `show` with the name
31
+ - ``kq pod ls`` -> normal subcommand
32
+ """
33
+
34
+ def __init__(self, *args, **kwargs) -> None:
35
+ self.default_command = kwargs.pop("default_command", "ls")
36
+ super().__init__(*args, **kwargs)
37
+
38
+ def get_command(self, ctx, cmd_name):
39
+ cmd = super().get_command(ctx, cmd_name)
40
+ if cmd is not None or cmd_name.startswith("-"):
41
+ return cmd
42
+ show = super().get_command(ctx, "show")
43
+ if show is None:
44
+ return None
45
+ ctx._implicit_name = cmd_name
46
+ return show
47
+
48
+
49
+ # -- shared option decorators ---------------------------------------------------
50
+
51
+
52
+ def output_option(fn):
53
+ return click.option(
54
+ "-o",
55
+ "--output",
56
+ "output_fmt",
57
+ type=click.Choice(FORMATS),
58
+ default=None,
59
+ help="Output format: yaml (default), json, rich, md, tree. [env: KQ_OUTPUT]",
60
+ )(fn)
61
+
62
+
63
+ def verbose_option(fn):
64
+ return click.option(
65
+ "-v",
66
+ "--verbose",
67
+ count=True,
68
+ default=0,
69
+ help="Verbosity: -v keep system metadata, -vv raw object.",
70
+ )(fn)
71
+
72
+
73
+ def namespace_option(fn):
74
+ return click.option(
75
+ "-n",
76
+ "--ns",
77
+ "--namespace",
78
+ "namespace",
79
+ default=None,
80
+ help="Namespace (default: current context's namespace).",
81
+ )(fn)
82
+
83
+
84
+ def all_namespaces_option(fn):
85
+ return click.option(
86
+ "-A",
87
+ "--all-namespaces",
88
+ "all_namespaces",
89
+ is_flag=True,
90
+ default=False,
91
+ help="All namespaces.",
92
+ )(fn)
93
+
94
+
95
+ def selector_options(fn):
96
+ fn = click.option(
97
+ "-l",
98
+ "--selector",
99
+ "label_selector",
100
+ default=None,
101
+ help="Label selector, e.g. app=web,tier=frontend.",
102
+ )(fn)
103
+ return click.option(
104
+ "--field-selector",
105
+ "field_selector",
106
+ default=None,
107
+ help="Field selector, e.g. status.phase=Running.",
108
+ )(fn)
109
+
110
+
111
+ def filter_option(fn):
112
+ return click.option(
113
+ "-f",
114
+ "--filter",
115
+ "name_filter",
116
+ default=None,
117
+ help="Regex filter on object name.",
118
+ )(fn)
119
+
120
+
121
+ def list_options(include_pattern: bool = True):
122
+ """Options for `ls`-style commands (order matters for help output)."""
123
+
124
+ def deco(fn):
125
+ if include_pattern:
126
+ fn = click.argument("pattern", required=False)(fn)
127
+ fn = filter_option(fn)
128
+ fn = all_namespaces_option(fn)
129
+ fn = selector_options(fn)
130
+ fn = namespace_option(fn)
131
+ fn = verbose_option(fn)
132
+ return output_option(fn)
133
+
134
+ return deco
135
+
136
+
137
+ def show_options():
138
+ """Options for `show`-style commands (NAME may come from group dispatch)."""
139
+
140
+ def deco(fn):
141
+ fn = click.argument("name", required=False)(fn)
142
+ fn = namespace_option(fn)
143
+ fn = verbose_option(fn)
144
+ return output_option(fn)
145
+
146
+ return deco
147
+
148
+
149
+ def group_options(fn):
150
+ """Options acceptable directly on a resource group (before the subcommand)."""
151
+ fn = all_namespaces_option(fn)
152
+ fn = namespace_option(fn)
153
+ fn = verbose_option(fn)
154
+ return output_option(fn)
155
+
156
+
157
+ def resolve_show_name(ctx: click.Context, name: str | None) -> str:
158
+ if name:
159
+ return name
160
+ implicit = getattr(getattr(ctx, "parent", None), "_implicit_name", None)
161
+ if implicit:
162
+ return implicit
163
+ raise click.UsageError("Missing NAME.")
164
+
165
+
166
+ def merge_group_options(settings, *, namespace=None, output_fmt=None, verbose=0, all_namespaces=False) -> None:
167
+ """Fold group-level option values into the shared Settings object."""
168
+ if namespace is not None:
169
+ settings.namespace = namespace
170
+ if output_fmt is not None:
171
+ settings.output = output_fmt
172
+ if verbose:
173
+ settings.verbose = max(settings.verbose, verbose)
174
+ if all_namespaces:
175
+ settings.all_namespaces = True
@@ -0,0 +1,92 @@
1
+ """Command registry for the kq root group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .api_resources import api
6
+ from .contexts import make_contexts_group
7
+ from .describe import describe
8
+ from .eso_cmd import eso
9
+ from .events_cmd import ev
10
+ from .get import get
11
+ from .groups import CONFIGMAPS, DEPLOYMENTS, NAMESPACES, NODES, PODS, SECRETS, SERVICES
12
+ from .helm_cmd import make_helm_group
13
+ from .logs import logs
14
+ from .overview import overview
15
+ from .tree_cmd import tree
16
+ from .version import version
17
+
18
+
19
+ def register(main) -> None:
20
+ """Attach all commands and their aliases to the root group."""
21
+ # triage first
22
+ main.add_command(overview, "overview")
23
+ main.add_command(overview, "ov", alias=True)
24
+ main.add_command(overview, "o", alias=True)
25
+ main.add_command(overview, "health", alias=True)
26
+
27
+ main.add_command(PODS, "pod")
28
+ main.add_command(PODS, "pods", alias=True)
29
+ main.add_command(PODS, "p", alias=True)
30
+ main.add_command(PODS, "po", alias=True)
31
+
32
+ main.add_command(NAMESPACES, "ns")
33
+ main.add_command(NAMESPACES, "namespaces", alias=True)
34
+ main.add_command(NAMESPACES, "namespace", alias=True)
35
+ main.add_command(NAMESPACES, "n", alias=True)
36
+
37
+ main.add_command(DEPLOYMENTS, "deploy")
38
+ main.add_command(DEPLOYMENTS, "deployments", alias=True)
39
+ main.add_command(DEPLOYMENTS, "deployment", alias=True)
40
+ main.add_command(DEPLOYMENTS, "dep", alias=True)
41
+ main.add_command(DEPLOYMENTS, "d", alias=True)
42
+
43
+ main.add_command(SERVICES, "svc")
44
+ main.add_command(SERVICES, "services", alias=True)
45
+ main.add_command(SERVICES, "service", alias=True)
46
+
47
+ main.add_command(SECRETS, "secret")
48
+ main.add_command(SECRETS, "secrets", alias=True)
49
+ main.add_command(SECRETS, "sec", alias=True)
50
+
51
+ main.add_command(CONFIGMAPS, "cm")
52
+ main.add_command(CONFIGMAPS, "configmaps", alias=True)
53
+ main.add_command(CONFIGMAPS, "configmap", alias=True)
54
+
55
+ main.add_command(NODES, "node")
56
+ main.add_command(NODES, "nodes", alias=True)
57
+ main.add_command(NODES, "no", alias=True)
58
+
59
+ main.add_command(ev, "ev")
60
+ main.add_command(ev, "events", alias=True)
61
+ main.add_command(ev, "event", alias=True)
62
+ main.add_command(ev, "e", alias=True)
63
+
64
+ main.add_command(logs, "logs")
65
+ main.add_command(logs, "log", alias=True)
66
+
67
+ main.add_command(make_helm_group(), "helm")
68
+ main.add_command(make_helm_group(), "h", alias=True)
69
+
70
+ main.add_command(eso, "eso")
71
+ main.add_command(eso, "externalsecrets", alias=True)
72
+
73
+ main.add_command(make_contexts_group(), "ctx")
74
+ main.add_command(make_contexts_group(), "contexts", alias=True)
75
+ main.add_command(make_contexts_group(), "context", alias=True)
76
+ main.add_command(make_contexts_group(), "c", alias=True)
77
+
78
+ main.add_command(get, "get")
79
+ main.add_command(get, "g", alias=True)
80
+
81
+ main.add_command(describe, "describe")
82
+ main.add_command(describe, "desc", alias=True)
83
+
84
+ main.add_command(tree, "tree")
85
+ main.add_command(tree, "t", alias=True)
86
+
87
+ main.add_command(api, "api")
88
+ main.add_command(api, "api-resources", alias=True)
89
+ main.add_command(api, "ar", alias=True)
90
+
91
+ main.add_command(version, "version")
92
+ main.add_command(version, "ver", alias=True)
@@ -0,0 +1,147 @@
1
+ """Shared command implementations: list / show rendering used by `get` and the sugar groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..access import get_object, list_objects
8
+ from ..cleanup import compact
9
+ from ..config import Settings
10
+ from ..discovery import ApiResource
11
+ from ..errors import KqError
12
+ from ..output import console, emit_data, note, print_object, render_rows
13
+ from ..summarize import columns_for, dig
14
+
15
+
16
+ def effective_namespace(settings: Settings, entry: ApiResource, eff, *, name_given: bool) -> str | None:
17
+ """Namespace for this call (None => all namespaces when listing)."""
18
+ if not entry.namespaced:
19
+ return None
20
+ if name_given:
21
+ return eff.namespace or settings.client.default_namespace()
22
+ if eff.all_namespaces:
23
+ return None
24
+ return eff.namespace or settings.client.default_namespace()
25
+
26
+
27
+ def apply_filter(items: list[dict], patterns: list[str | None]) -> list[dict]:
28
+ pats = [p for p in patterns if p]
29
+ if not pats:
30
+ return items
31
+ regexes = []
32
+ for p in pats:
33
+ try:
34
+ regexes.append(re.compile(p, re.IGNORECASE))
35
+ except re.error as e:
36
+ raise KqError(f"invalid filter regex '{p}': {e}") from None
37
+ return [it for it in items if all(rx.search(dig(it, "metadata", "name", default="") or "") for rx in regexes)]
38
+
39
+
40
+ def sort_items(items: list[dict]) -> list[dict]:
41
+ return sorted(
42
+ items,
43
+ key=lambda o: (dig(o, "metadata", "namespace", default="") or "", dig(o, "metadata", "name", default="") or ""),
44
+ )
45
+
46
+
47
+ def tabulate(entry: ApiResource, items: list[dict], *, include_ns: bool) -> tuple[list[str], list[dict]]:
48
+ cols = columns_for(entry.kind)
49
+ if include_ns:
50
+ cols = [("NAMESPACE", lambda o: dig(o, "metadata", "namespace", default="") or ""), *cols]
51
+ headers = [h for h, _ in cols]
52
+ rows = []
53
+ for it in items:
54
+ r = {}
55
+ for h, fn in cols:
56
+ try:
57
+ r[h] = fn(it)
58
+ except Exception:
59
+ r[h] = ""
60
+ rows.append(r)
61
+ return headers, rows
62
+
63
+
64
+ def render_list(settings: Settings, entry: ApiResource, items: list[dict], eff, *, namespace: str | None) -> None:
65
+ """Render a list of objects in the requested format."""
66
+ if eff.output in ("yaml", "json"):
67
+ emit_data([compact(it, eff.verbose) for it in items], eff.output)
68
+ if not items:
69
+ where = f" in namespace '{namespace}'" if namespace else ""
70
+ note(f"no {entry.plural} found{where} (0 items)")
71
+ return
72
+
73
+ if eff.output == "tree":
74
+ from ..tree import forest_for_items, rich_forest
75
+
76
+ forest = forest_for_items(settings.client, entry, items)
77
+ console().print(rich_forest(forest))
78
+ return
79
+
80
+ include_ns = bool(
81
+ items
82
+ and any(dig(o, "metadata", "namespace") for o in items)
83
+ and (namespace is None or len({dig(o, "metadata", "namespace", default="") or "" for o in items}) > 1)
84
+ )
85
+ headers, rows = tabulate(entry, items, include_ns=include_ns)
86
+ where = f" in namespace '{namespace}'" if namespace else " (all namespaces)"
87
+ subtitle = f"{len(items)} {entry.plural}{where}"
88
+
89
+ if not items:
90
+ console().print(f"[dim]no {entry.plural} found{where}[/dim]")
91
+ return
92
+
93
+ render_rows(headers, rows, eff.output, title=entry.kind, subtitle=subtitle)
94
+
95
+
96
+ def render_single(
97
+ settings: Settings,
98
+ entry: ApiResource,
99
+ name: str,
100
+ eff,
101
+ *,
102
+ transform=None,
103
+ ) -> None:
104
+ """Fetch and render one object (`show`-style)."""
105
+ ns = effective_namespace(settings, entry, eff, name_given=True)
106
+ obj = get_object(settings.client, entry, name, namespace=ns)
107
+
108
+ if eff.output == "tree":
109
+ from ..tree import build_forest, rich_forest
110
+
111
+ console().print(rich_forest(build_forest(settings.client, entry, obj)))
112
+ return
113
+
114
+ data = compact(obj, eff.verbose)
115
+ if transform is not None:
116
+ data = transform(data)
117
+
118
+ if eff.output in ("yaml", "json"):
119
+ emit_data(data, eff.output)
120
+ elif eff.output in ("rich", "md"):
121
+ title = f"{entry.kind} {name}" + (f" -n {ns}" if ns else "")
122
+ print_object(data, eff.output, title=title)
123
+ else:
124
+ raise KqError(f"format '{eff.output}' is not supported here")
125
+
126
+
127
+ def fetch_list(
128
+ settings: Settings,
129
+ entry: ApiResource,
130
+ eff,
131
+ *,
132
+ label_selector: str | None = None,
133
+ field_selector: str | None = None,
134
+ pattern: str | None = None,
135
+ name_filter: str | None = None,
136
+ ) -> tuple[list[dict], str | None]:
137
+ """Resolve namespace, list + filter + sort. Returns (items, namespace)."""
138
+ ns = effective_namespace(settings, entry, eff, name_given=False)
139
+ items = list_objects(
140
+ settings.client,
141
+ entry,
142
+ namespace=ns,
143
+ label_selector=label_selector,
144
+ field_selector=field_selector,
145
+ )
146
+ items = sort_items(apply_filter(items, [pattern, name_filter]))
147
+ return items, ns