localargo 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.
Files changed (42) hide show
  1. localargo/__about__.py +6 -0
  2. localargo/__init__.py +6 -0
  3. localargo/__main__.py +11 -0
  4. localargo/cli/__init__.py +49 -0
  5. localargo/cli/commands/__init__.py +5 -0
  6. localargo/cli/commands/app.py +150 -0
  7. localargo/cli/commands/cluster.py +312 -0
  8. localargo/cli/commands/debug.py +478 -0
  9. localargo/cli/commands/port_forward.py +311 -0
  10. localargo/cli/commands/secrets.py +300 -0
  11. localargo/cli/commands/sync.py +291 -0
  12. localargo/cli/commands/template.py +288 -0
  13. localargo/cli/commands/up.py +341 -0
  14. localargo/config/__init__.py +15 -0
  15. localargo/config/manifest.py +520 -0
  16. localargo/config/store.py +66 -0
  17. localargo/core/__init__.py +6 -0
  18. localargo/core/apps.py +330 -0
  19. localargo/core/argocd.py +509 -0
  20. localargo/core/catalog.py +284 -0
  21. localargo/core/cluster.py +149 -0
  22. localargo/core/k8s.py +140 -0
  23. localargo/eyecandy/__init__.py +15 -0
  24. localargo/eyecandy/progress_steps.py +283 -0
  25. localargo/eyecandy/table_renderer.py +154 -0
  26. localargo/eyecandy/tables.py +57 -0
  27. localargo/logging.py +99 -0
  28. localargo/manager.py +232 -0
  29. localargo/providers/__init__.py +6 -0
  30. localargo/providers/base.py +146 -0
  31. localargo/providers/k3s.py +206 -0
  32. localargo/providers/kind.py +326 -0
  33. localargo/providers/registry.py +52 -0
  34. localargo/utils/__init__.py +4 -0
  35. localargo/utils/cli.py +231 -0
  36. localargo/utils/proc.py +148 -0
  37. localargo/utils/retry.py +58 -0
  38. localargo-0.1.0.dist-info/METADATA +149 -0
  39. localargo-0.1.0.dist-info/RECORD +42 -0
  40. localargo-0.1.0.dist-info/WHEEL +4 -0
  41. localargo-0.1.0.dist-info/entry_points.txt +2 -0
  42. localargo-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,284 @@
1
+ """App catalog schema, loader, and profile overlays for LocalArgo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, cast
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class AppSpec: # pylint: disable=too-many-instance-attributes
14
+ """Declarative app specification loaded from localargo.yaml."""
15
+
16
+ name: str
17
+ repo: str
18
+ path: str = "."
19
+ type: Literal["kustomize", "helm"] = "kustomize"
20
+ namespace: str = "default"
21
+ project: str = "default"
22
+ sync_policy: Literal["manual", "auto"] = "manual"
23
+ helm_values: list[str] = field(default_factory=list)
24
+ health_timeout: int = 300
25
+ # Optional: if provided, deployment will be done via `kubectl apply -f` on these files
26
+ # instead of using the ArgoCD CLI to create/update applications.
27
+ manifest_files: list[str] = field(default_factory=list)
28
+
29
+
30
+ @dataclass
31
+ class AppState:
32
+ """Observed state of an ArgoCD application."""
33
+
34
+ name: str
35
+ namespace: str
36
+ health: Literal["Healthy", "Progressing", "Degraded", "Unknown"]
37
+ sync: Literal["Synced", "OutOfSync", "Unknown"]
38
+ revision: str | None = None
39
+
40
+
41
+ class CatalogError(ValueError):
42
+ """Raised on invalid catalog content."""
43
+
44
+
45
+ def load_catalog(path: str = "localargo.yaml", profile: str | None = None) -> list[AppSpec]:
46
+ """Load app catalog, applying optional profile overlay if present."""
47
+ base: dict[str, Any] = _safe_load_yaml(path)
48
+ specs = _parse_apps(base)
49
+ if profile:
50
+ overlay_path = f"localargo.{profile}.yaml"
51
+ if os.path.exists(overlay_path):
52
+ overlay = _safe_load_yaml(overlay_path)
53
+ specs = _merge_overlays(specs, overlay)
54
+ _validate(specs)
55
+ return specs
56
+
57
+
58
+ def _safe_load_yaml(path: str) -> dict[str, Any]:
59
+ if not os.path.exists(path):
60
+ return {}
61
+ with open(path, encoding="utf-8") as f:
62
+ data = yaml.safe_load(f) or {}
63
+ if not isinstance(data, dict):
64
+ msg = "Top-level YAML must be a mapping"
65
+ raise CatalogError(msg)
66
+ return data
67
+
68
+
69
+ def _parse_apps(data: dict[str, Any]) -> list[AppSpec]:
70
+ apps_val = data.get("apps", [])
71
+ if apps_val is None:
72
+ return []
73
+ if not isinstance(apps_val, list):
74
+ msg = "'apps' must be a list"
75
+ raise CatalogError(msg)
76
+ specs: list[AppSpec] = []
77
+ for idx, raw in enumerate(apps_val):
78
+ specs.append(_build_spec_from_raw(raw, idx))
79
+ return specs
80
+
81
+
82
+ def _merge_overlays(base_specs: list[AppSpec], overlay: dict[str, Any]) -> list[AppSpec]:
83
+ overlays = overlay.get("apps", [])
84
+ if not overlays:
85
+ return base_specs
86
+ if not isinstance(overlays, list):
87
+ msg = "overlay 'apps' must be a list"
88
+ raise CatalogError(msg)
89
+ by_name: dict[str, AppSpec] = {s.name: s for s in base_specs}
90
+ for idx, raw in enumerate(overlays):
91
+ _apply_overlay_to_map(by_name, raw, idx)
92
+ return list(by_name.values())
93
+
94
+
95
+ def _build_spec_from_raw(raw: Any, idx: int) -> AppSpec:
96
+ if not isinstance(raw, dict):
97
+ msg = f"apps[{idx}] must be a mapping"
98
+ raise CatalogError(msg)
99
+ name = _require_str(raw, "name", idx)
100
+ repo = _require_str(raw, "repo", idx)
101
+ path = str(raw.get("path", "."))
102
+ app_type = _parse_app_type(raw, idx)
103
+ namespace = str(raw.get("namespace", "default"))
104
+ project = str(raw.get("project", "default"))
105
+ sync_policy = _parse_sync_policy(raw, idx)
106
+ helm_values = _parse_helm_values(raw, idx)
107
+ health_timeout = _parse_health_timeout(raw)
108
+ manifest_files = _parse_manifest_files(raw)
109
+ return AppSpec(
110
+ name=name,
111
+ repo=repo,
112
+ path=path,
113
+ type=app_type,
114
+ namespace=namespace,
115
+ project=project,
116
+ sync_policy=sync_policy,
117
+ helm_values=[str(v) for v in helm_values],
118
+ health_timeout=health_timeout,
119
+ manifest_files=manifest_files,
120
+ )
121
+
122
+
123
+ def _parse_app_type(raw: dict[str, Any], idx: int) -> Literal["kustomize", "helm"]:
124
+ app_type = raw.get("type", "kustomize")
125
+ if app_type not in ("kustomize", "helm"):
126
+ msg = f"apps[{idx}].type must be 'kustomize' or 'helm'"
127
+ raise CatalogError(msg)
128
+ return cast(Literal["kustomize", "helm"], app_type)
129
+
130
+
131
+ def _parse_sync_policy(raw: dict[str, Any], idx: int) -> Literal["manual", "auto"]:
132
+ policy = raw.get("syncPolicy", "manual")
133
+ if policy not in ("manual", "auto"):
134
+ msg = f"apps[{idx}].syncPolicy must be 'manual' or 'auto'"
135
+ raise CatalogError(msg)
136
+ return cast(Literal["manual", "auto"], policy)
137
+
138
+
139
+ def _parse_helm_values(raw: dict[str, Any], idx: int) -> list[str]:
140
+ helm_values = raw.get("helmValues", []) or []
141
+ if not isinstance(helm_values, list) or not all(
142
+ isinstance(v, str | os.PathLike) for v in helm_values
143
+ ):
144
+ msg = f"apps[{idx}].helmValues must be a list of strings"
145
+ raise CatalogError(msg)
146
+ return [str(v) for v in helm_values]
147
+
148
+
149
+ def _parse_health_timeout(raw: dict[str, Any]) -> int:
150
+ return int(raw.get("healthTimeout", 300))
151
+
152
+
153
+ def _normalize_manifest_files(value: Any) -> list[str]:
154
+ if value is None:
155
+ return []
156
+ if isinstance(value, list) and all(isinstance(v, str | os.PathLike) for v in value):
157
+ return [str(v) for v in value]
158
+ msg = "manifest files must be a list of strings"
159
+ raise CatalogError(msg)
160
+
161
+
162
+ def _parse_manifest_files(raw: dict[str, Any]) -> list[str]:
163
+ # Support both snake_case and camelCase
164
+ vals = []
165
+ if "manifest_files" in raw and raw["manifest_files"] is not None:
166
+ vals.extend(_normalize_manifest_files(raw["manifest_files"]))
167
+ if "manifestFiles" in raw and raw["manifestFiles"] is not None:
168
+ vals.extend(_normalize_manifest_files(raw["manifestFiles"]))
169
+ return vals
170
+
171
+
172
+ def _apply_overlay_to_map(by_name: dict[str, AppSpec], raw: Any, idx: int) -> None:
173
+ if not isinstance(raw, dict):
174
+ msg = f"overlay apps[{idx}] must be a mapping"
175
+ raise CatalogError(msg)
176
+ name = _require_str(raw, "name", idx)
177
+ base = by_name.get(name)
178
+ if not base:
179
+ merged_list = _parse_apps({"apps": [raw]})
180
+ if merged_list:
181
+ by_name[name] = merged_list[0]
182
+ return
183
+ _apply_overlay_to_spec(base, raw)
184
+
185
+
186
+ def _apply_overlay_to_spec(base: AppSpec, raw: dict[str, Any]) -> None:
187
+ _overlay_repo(base, raw)
188
+ _overlay_path(base, raw)
189
+ _overlay_type(base, raw)
190
+ _overlay_namespace(base, raw)
191
+ _overlay_project(base, raw)
192
+ _overlay_sync_policy(base, raw)
193
+ _overlay_helm_values(base, raw)
194
+ _overlay_health_timeout(base, raw)
195
+ _overlay_manifest_files(base, raw)
196
+
197
+
198
+ def _overlay_repo(base: AppSpec, raw: dict[str, Any]) -> None:
199
+ if "repo" in raw:
200
+ base.repo = str(raw["repo"])
201
+
202
+
203
+ def _overlay_path(base: AppSpec, raw: dict[str, Any]) -> None:
204
+ if "path" in raw:
205
+ base.path = str(raw["path"])
206
+
207
+
208
+ def _overlay_type(base: AppSpec, raw: dict[str, Any]) -> None:
209
+ if "type" in raw:
210
+ if raw["type"] not in ("kustomize", "helm"):
211
+ msg = "overlay type must be 'kustomize' or 'helm'"
212
+ raise CatalogError(msg)
213
+ base.type = cast(Literal["kustomize", "helm"], raw["type"])
214
+
215
+
216
+ def _overlay_namespace(base: AppSpec, raw: dict[str, Any]) -> None:
217
+ if "namespace" in raw:
218
+ base.namespace = str(raw["namespace"])
219
+
220
+
221
+ def _overlay_project(base: AppSpec, raw: dict[str, Any]) -> None:
222
+ if "project" in raw:
223
+ base.project = str(raw["project"])
224
+
225
+
226
+ def _overlay_sync_policy(base: AppSpec, raw: dict[str, Any]) -> None:
227
+ if "syncPolicy" in raw:
228
+ policy = raw["syncPolicy"]
229
+ if policy not in ("manual", "auto"):
230
+ msg = "overlay syncPolicy must be 'manual' or 'auto'"
231
+ raise CatalogError(msg)
232
+ base.sync_policy = cast(Literal["manual", "auto"], policy)
233
+
234
+
235
+ def _overlay_helm_values(base: AppSpec, raw: dict[str, Any]) -> None:
236
+ if "helmValues" in raw:
237
+ base.helm_values = _normalize_overlay_helm_values(raw["helmValues"])
238
+
239
+
240
+ def _overlay_health_timeout(base: AppSpec, raw: dict[str, Any]) -> None:
241
+ if "healthTimeout" in raw:
242
+ base.health_timeout = int(raw["healthTimeout"])
243
+
244
+
245
+ def _overlay_manifest_files(base: AppSpec, raw: dict[str, Any]) -> None:
246
+ # Merge manifest files if present in overlay
247
+ files = []
248
+ if "manifest_files" in raw:
249
+ files.extend(_normalize_manifest_files(raw["manifest_files"]))
250
+ if "manifestFiles" in raw:
251
+ files.extend(_normalize_manifest_files(raw["manifestFiles"]))
252
+ if files:
253
+ base.manifest_files = files
254
+
255
+
256
+ def _normalize_overlay_helm_values(value: Any) -> list[str]:
257
+ hv = value or []
258
+ if not isinstance(hv, list):
259
+ msg = "overlay helmValues must be a list of strings"
260
+ raise CatalogError(msg)
261
+ for v in hv:
262
+ if not isinstance(v, str | os.PathLike):
263
+ msg = "overlay helmValues must be a list of strings"
264
+ raise CatalogError(msg)
265
+ return [str(v) for v in hv]
266
+
267
+
268
+ def _validate(specs: list[AppSpec]) -> None:
269
+ seen: set[str] = set()
270
+ for s in specs:
271
+ if not s.name or not s.repo:
272
+ msg = "Each app requires non-empty 'name' and 'repo'"
273
+ raise CatalogError(msg)
274
+ if s.name in seen:
275
+ msg = f"Duplicate app name: {s.name}"
276
+ raise CatalogError(msg)
277
+ seen.add(s.name)
278
+
279
+
280
+ def _require_str(raw: dict[str, Any], key: str, idx: int) -> str:
281
+ if key not in raw or raw[key] is None or str(raw[key]).strip() == "":
282
+ msg = f"apps[{idx}].{key} is required"
283
+ raise CatalogError(msg)
284
+ return str(raw[key])
@@ -0,0 +1,149 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Core cluster management functionality."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from typing import TYPE_CHECKING, Any, ClassVar
10
+
11
+ from localargo.logging import logger
12
+ from localargo.providers.k3s import K3sProvider
13
+ from localargo.providers.kind import KindProvider
14
+ from localargo.utils.cli import check_cli_availability, run_subprocess
15
+
16
+ if TYPE_CHECKING:
17
+ from localargo.providers.base import ClusterProvider
18
+
19
+
20
+ class ClusterManager:
21
+ """High-level cluster management operations."""
22
+
23
+ PROVIDERS: ClassVar[dict[str, type[ClusterProvider]]] = {
24
+ "kind": KindProvider,
25
+ "k3s": K3sProvider,
26
+ }
27
+
28
+ def __init__(self) -> None:
29
+ self.providers = {name: cls() for name, cls in self.PROVIDERS.items()}
30
+
31
+ def get_available_providers(self) -> list[str]:
32
+ """Get list of available (installed) providers."""
33
+ return [name for name, provider in self.providers.items() if provider.is_available()]
34
+
35
+ def get_provider(self, name: str) -> ClusterProvider:
36
+ """Get a provider instance by name."""
37
+ if name not in self.providers:
38
+ msg = f"Unknown provider: {name}"
39
+ raise ValueError(msg)
40
+ return self.providers[name]
41
+
42
+ def create_cluster(
43
+ self, provider_name: str, cluster_name: str = "localargo", **kwargs: Any
44
+ ) -> bool:
45
+ """Create a cluster using the specified provider."""
46
+ provider = self.get_provider(provider_name)
47
+ provider.name = cluster_name
48
+ return provider.create_cluster(**kwargs)
49
+
50
+ def delete_cluster(self, provider_name: str, cluster_name: str | None = None) -> bool:
51
+ """Delete a cluster using the specified provider."""
52
+ provider = self.get_provider(provider_name)
53
+ return provider.delete_cluster(cluster_name)
54
+
55
+ def get_cluster_status(
56
+ self, provider_name: str | None = None, cluster_name: str | None = None
57
+ ) -> dict[str, Any]:
58
+ """Get cluster status. If provider not specified, check current context."""
59
+ if provider_name:
60
+ provider = self.get_provider(provider_name)
61
+ return provider.get_cluster_status(cluster_name)
62
+
63
+ # Try to detect current cluster provider from context
64
+ try:
65
+ kubectl_path = check_cli_availability("kubectl")
66
+ if not kubectl_path:
67
+ return {}
68
+ result = run_subprocess(
69
+ [kubectl_path, "config", "current-context"],
70
+ )
71
+ current_context = result.stdout.strip()
72
+
73
+ # Try to identify provider from context name
74
+ for provider in self.providers.values():
75
+ if current_context.startswith(f"{provider.provider_name}-"):
76
+ status = provider.get_cluster_status()
77
+ status["detected_from_context"] = True
78
+ return status
79
+ except subprocess.CalledProcessError:
80
+ return {
81
+ "provider": "none",
82
+ "name": "none",
83
+ "context": "none",
84
+ "exists": False,
85
+ "ready": False,
86
+ "error": "kubectl not available or no current context",
87
+ }
88
+
89
+ # Fallback: generic status - only if try succeeds but no provider found
90
+ return {
91
+ "provider": "unknown",
92
+ "name": "unknown",
93
+ "context": current_context,
94
+ "exists": True,
95
+ "ready": True,
96
+ "detected_from_context": True,
97
+ }
98
+
99
+ def list_clusters(self) -> list[dict[str, Any]]:
100
+ """List all clusters across all providers."""
101
+ clusters = []
102
+
103
+ for provider in self.providers.values():
104
+ if provider.is_available():
105
+ try:
106
+ status = provider.get_cluster_status()
107
+ clusters.append(status)
108
+ except (subprocess.SubprocessError, OSError) as e:
109
+ # Skip clusters we can't get status for
110
+ logger.warning(
111
+ "Failed to get status for cluster from %s: %s",
112
+ provider.provider_name,
113
+ e,
114
+ )
115
+ continue
116
+
117
+ return clusters
118
+
119
+ def switch_context(self, context_name: str) -> bool:
120
+ """Switch to a different kubectl context."""
121
+ try:
122
+ kubectl_path = check_cli_availability("kubectl")
123
+ if not kubectl_path:
124
+ return False
125
+ run_subprocess(
126
+ [kubectl_path, "config", "use-context", context_name], check=True
127
+ ) # Show output for debugging
128
+ except subprocess.CalledProcessError:
129
+ return False
130
+
131
+ return True
132
+
133
+ def get_contexts(self) -> list[str]:
134
+ """Get list of available kubectl contexts."""
135
+ try:
136
+ kubectl_path = check_cli_availability("kubectl")
137
+ if not kubectl_path:
138
+ return []
139
+ result = run_subprocess(
140
+ [kubectl_path, "config", "get-contexts", "-o", "name"],
141
+ )
142
+ contexts = result.stdout.strip().split("\n")
143
+ return [ctx for ctx in contexts if ctx] # Filter out empty strings
144
+ except subprocess.CalledProcessError:
145
+ return []
146
+
147
+
148
+ # Global cluster manager instance
149
+ cluster_manager = ClusterManager()
localargo/core/k8s.py ADDED
@@ -0,0 +1,140 @@
1
+ """Kubernetes helpers for app pod discovery, log streaming, and manifest apply."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess as sp
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from localargo.logging import logger
11
+ from localargo.utils.cli import run_subprocess
12
+ from localargo.utils.proc import run_json, run_stream
13
+
14
+
15
+ def _kubeconfig_args(kubeconfig: str | None) -> list[str]:
16
+ if not kubeconfig:
17
+ return []
18
+ # Allow both file paths and directories (ignore directories gracefully)
19
+ path = Path(kubeconfig)
20
+ if path.exists() and path.is_file():
21
+ return ["--kubeconfig", str(path)]
22
+ return ["--kubeconfig", str(path)]
23
+
24
+
25
+ def apply_manifests(files: list[str], *, kubeconfig: str | None = None) -> None:
26
+ """Apply one or more manifest files using kubectl apply -f.
27
+
28
+ Args:
29
+ files (list[str]): List of file paths (YAML files or directories). Each
30
+ will be passed to kubectl via repeated -f flags.
31
+ kubeconfig (str | None): Optional kubeconfig file path. When provided,
32
+ it's passed to kubectl via --kubeconfig.
33
+ """
34
+ if not files:
35
+ return
36
+ args: list[str] = ["kubectl", *(_kubeconfig_args(kubeconfig)), "apply"]
37
+ for f in files:
38
+ args.extend(["-f", f])
39
+ logger.info("Applying manifests: %s", ", ".join(files))
40
+ run_subprocess(args)
41
+
42
+
43
+ def ensure_namespace(namespace: str) -> None:
44
+ """Create namespace if it does not exist."""
45
+ args = ["kubectl", "get", "ns", namespace, "-o", "name"]
46
+ result = run_subprocess(args, check=False)
47
+ if result.returncode != 0:
48
+ run_subprocess(["kubectl", "create", "ns", namespace])
49
+
50
+
51
+ def upsert_secret(namespace: str, secret_name: str, data: dict[str, str]) -> None:
52
+ """Create or update a generic secret with provided key/value pairs.
53
+
54
+ Values are passed from environment; empty values are allowed and result in empty strings.
55
+ """
56
+ ensure_namespace(namespace)
57
+ # Try create
58
+ base = ["kubectl", "-n", namespace, "create", "secret", "generic", secret_name]
59
+ for k, v in data.items():
60
+ base.extend(["--from-literal", f"{k}={v}"])
61
+ create_result = run_subprocess(base, check=False)
62
+ if create_result.returncode == 0:
63
+ return
64
+
65
+ # Fallback to kubectl create secret generic --dry-run=client -o yaml | kubectl apply -f -
66
+ kubectl_path = shutil.which("kubectl")
67
+ if not kubectl_path:
68
+ msg = "kubectl not found"
69
+ raise FileNotFoundError(msg)
70
+ dry = [
71
+ kubectl_path,
72
+ "-n",
73
+ namespace,
74
+ "create",
75
+ "secret",
76
+ "generic",
77
+ secret_name,
78
+ "--dry-run=client",
79
+ "-o",
80
+ "yaml",
81
+ ]
82
+ for k, v in data.items():
83
+ dry.extend(["--from-literal", f"{k}={v}"])
84
+ # Pipe into apply using captured output for safer resource handling
85
+ dry_result = sp.run(dry, check=True, capture_output=True)
86
+ sp.run([kubectl_path, "apply", "-f", "-"], check=True, input=dry_result.stdout)
87
+
88
+
89
+ if TYPE_CHECKING: # imported only for type checking
90
+ from collections.abc import Iterator
91
+
92
+
93
+ def list_pods_for_app(app: str, namespace: str) -> list[str]:
94
+ """List pods associated with an app using common label conventions."""
95
+ obj = run_json(["kubectl", "get", "pods", "-n", namespace, "-o", "json"])
96
+ items = obj.get("items", []) if isinstance(obj, dict) else []
97
+ pods: list[str] = []
98
+ for item in items:
99
+ matched = _extract_pod_name_if_matches(item, app)
100
+ if matched:
101
+ pods.append(matched)
102
+ return pods
103
+
104
+
105
+ def _matches_app(labels: dict[str, Any], app: str) -> bool:
106
+ values = [
107
+ labels.get("app.kubernetes.io/instance"),
108
+ labels.get("app.kubernetes.io/name"),
109
+ labels.get("app"),
110
+ labels.get("argo-app"),
111
+ ]
112
+ return any(isinstance(v, str) and v == app for v in values)
113
+
114
+
115
+ def _extract_pod_name_if_matches(item: Any, app: str) -> str | None:
116
+ meta = item.get("metadata", {}) if isinstance(item, dict) else {}
117
+ name = meta.get("name")
118
+ labels = meta.get("labels", {}) or {}
119
+ if isinstance(labels, dict) and _matches_app(labels, app) and isinstance(name, str):
120
+ return name
121
+ return None
122
+
123
+
124
+ def stream_logs(
125
+ pod: str,
126
+ namespace: str,
127
+ *,
128
+ container: str | None = None,
129
+ since: str | None = None,
130
+ follow: bool = True,
131
+ ) -> Iterator[str]:
132
+ """Stream logs from a pod, yielding lines as strings."""
133
+ args = ["kubectl", "logs", pod, "-n", namespace]
134
+ if container:
135
+ args.extend(["-c", container])
136
+ if since:
137
+ args.extend(["--since", since])
138
+ if follow:
139
+ args.append("-f")
140
+ return run_stream(args)
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ """Eyecandy UI abstractions for LocalArgo CLI.
7
+
8
+ This module provides Rich-based UI abstractions for the LocalArgo CLI,
9
+ including table rendering, progress steps, and enhanced CLI styling.
10
+ """
11
+
12
+ from localargo.eyecandy.progress_steps import StepLogger
13
+ from localargo.eyecandy.table_renderer import TableRenderer
14
+
15
+ __all__ = ["TableRenderer", "StepLogger"]