fadenstack 0.4.2__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 (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,484 @@
1
+ """Docker Compose subprocess wrapper.
2
+
3
+ Wraps ``docker compose`` (v2) as a thin Python layer. Every function
4
+ shells out rather than using a Docker SDK so the CLI has zero runtime
5
+ dependency on Docker libraries and works on any system that has the
6
+ ``docker`` binary.
7
+
8
+ Environment and profile arguments are built from the
9
+ :class:`~faden_cli.core.settings.FadenConfig` dataclass so callers
10
+ never need to assemble raw CLI flags.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from faden_cli.core.console import console, error
23
+
24
+
25
+ # ── GPU detection ─────────────────────────────────────────────────
26
+
27
+ _gpu_cache: bool | None = None
28
+
29
+
30
+ def has_nvidia_gpu() -> bool:
31
+ """Return True if an NVIDIA GPU is actually usable by Docker.
32
+
33
+ Checks two things:
34
+ 1. The Docker daemon has the ``nvidia`` runtime registered.
35
+ 2. ``nvidia-smi`` succeeds on the host, proving real GPU
36
+ adapters are present (the runtime alone may be installed
37
+ in WSL/CI without any physical GPU).
38
+ """
39
+ global _gpu_cache # noqa: PLW0603
40
+ if _gpu_cache is not None:
41
+ return _gpu_cache
42
+
43
+ docker = shutil.which("docker")
44
+ if not docker:
45
+ _gpu_cache = False
46
+ return False
47
+
48
+ # 1. Runtime check
49
+ try:
50
+ proc = subprocess.run(
51
+ [docker, "info", "--format", "{{.Runtimes}}"],
52
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
53
+ )
54
+ if "nvidia" not in (proc.stdout or "").lower():
55
+ _gpu_cache = False
56
+ return False
57
+ except Exception: # noqa: BLE001
58
+ _gpu_cache = False
59
+ return False
60
+
61
+ # 2. Actual GPU adapter check
62
+ smi = shutil.which("nvidia-smi")
63
+ if not smi:
64
+ _gpu_cache = False
65
+ return False
66
+ try:
67
+ proc = subprocess.run(
68
+ [smi, "-L"],
69
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
70
+ )
71
+ _gpu_cache = proc.returncode == 0 and "GPU" in (proc.stdout or "")
72
+ except Exception: # noqa: BLE001
73
+ _gpu_cache = False
74
+ return _gpu_cache
75
+
76
+
77
+ # ── Types ─────────────────────────────────────────────────────────
78
+
79
+
80
+ @dataclass
81
+ class ComposeService:
82
+ """Parsed row from ``docker compose ps --format json``."""
83
+
84
+ name: str = ""
85
+ service: str = ""
86
+ status: str = "" # running, exited, created, …
87
+ health: str = "" # healthy, unhealthy, starting, ""
88
+ ports: str = ""
89
+ state: str = ""
90
+ exit_code: int = 0
91
+
92
+
93
+ @dataclass
94
+ class ComposeContext:
95
+ """Everything needed to run a compose command."""
96
+
97
+ compose_files: list[Path] = field(default_factory=list)
98
+ env_file: Path | None = None
99
+ project_dir: Path | None = None
100
+ profiles: list[str] = field(default_factory=list)
101
+
102
+ def base_cmd(self) -> list[str]:
103
+ """Build the ``docker compose`` prefix with all file/profile flags."""
104
+ docker = shutil.which("docker")
105
+ if not docker:
106
+ error("docker not found on PATH")
107
+ sys.exit(1)
108
+
109
+ cmd: list[str] = [docker, "compose"]
110
+ for f in self.compose_files:
111
+ cmd.extend(["-f", str(f)])
112
+ if self.env_file and Path(str(self.env_file)).exists():
113
+ cmd.extend(["--env-file", str(self.env_file)])
114
+ if self.project_dir:
115
+ cmd.extend(["--project-directory", str(self.project_dir)])
116
+ for p in self.profiles:
117
+ cmd.extend(["--profile", p])
118
+ return cmd
119
+
120
+
121
+ # ── Constants ─────────────────────────────────────────────────────
122
+
123
+ # Base images used in the platform base Dockerfile. Pre-pulling
124
+ # puts them in the local store so subsequent builds hit cache.
125
+ _BASE_IMAGES = [
126
+ "python:3.13-slim-bookworm",
127
+ "ghcr.io/astral-sh/uv:0.9.12",
128
+ ]
129
+
130
+
131
+ # ── Public API ────────────────────────────────────────────────────
132
+
133
+
134
+ def pull_base_images() -> None:
135
+ """Pre-pull shared base images so BuildKit finds them locally."""
136
+ docker = shutil.which("docker")
137
+ if not docker:
138
+ return
139
+ for image in _BASE_IMAGES:
140
+ subprocess.run( # noqa: S603
141
+ [docker, "pull", image],
142
+ check=False,
143
+ stdout=subprocess.DEVNULL,
144
+ stderr=subprocess.DEVNULL,
145
+ )
146
+
147
+
148
+ def build_base_image(ctx: ComposeContext, *, no_cache: bool = False) -> int:
149
+ """Build the shared platform base image via compose.
150
+
151
+ Uses the ``faden-base`` service (gated behind the ``_build``
152
+ profile) so the image lands in the same BuildKit context that
153
+ subsequent service builds use.
154
+ """
155
+ base_ctx = ComposeContext(
156
+ compose_files=ctx.compose_files,
157
+ env_file=ctx.env_file,
158
+ project_dir=ctx.project_dir,
159
+ profiles=["_build"],
160
+ )
161
+ return build(base_ctx, services=["faden-base"], no_cache=no_cache)
162
+
163
+
164
+ def _run(
165
+ cmd: list[str],
166
+ *,
167
+ capture: bool = False,
168
+ check: bool = True,
169
+ stream: bool = False,
170
+ ) -> subprocess.CompletedProcess[str]:
171
+ """Execute a command, optionally streaming output to the console."""
172
+ if stream:
173
+ # Stream stdout/stderr to terminal in real time
174
+ result = subprocess.run(cmd, check=False) # noqa: S603
175
+ if check and result.returncode != 0:
176
+ error(f"Command failed with exit code {result.returncode}")
177
+ return subprocess.CompletedProcess(cmd, result.returncode, "", "")
178
+
179
+ return subprocess.run( # noqa: S603
180
+ cmd,
181
+ capture_output=capture,
182
+ text=True,
183
+ # Decode explicitly as UTF-8 and never raise. `text=True` alone
184
+ # decodes with the locale codec, which on a Windows console is
185
+ # cp1252: docker output containing any byte outside it (0x8d in a
186
+ # container name or a progress glyph) killed the reader thread, left
187
+ # `stdout` as None, and surfaced as `'NoneType' has no attribute
188
+ # 'strip'` in the caller rather than as a decoding problem.
189
+ encoding="utf-8",
190
+ errors="replace",
191
+ check=False,
192
+ )
193
+
194
+
195
+ def up(
196
+ ctx: ComposeContext,
197
+ *,
198
+ services: list[str] | None = None,
199
+ detach: bool = True,
200
+ build: bool = False,
201
+ pull: str = "",
202
+ wait: bool = False,
203
+ timeout: int = 0,
204
+ force_recreate: bool = False,
205
+ no_deps: bool = False,
206
+ ) -> int:
207
+ """Run ``docker compose up``.
208
+
209
+ *pull* accepts ``"always"``, ``"missing"``, or ``"never"``.
210
+ An empty string (default) leaves Docker Compose to decide.
211
+ *wait* blocks until services are healthy (implies ``-d``).
212
+ *timeout* sets the max wait time in seconds (requires *wait*).
213
+ *force_recreate* recreates containers even if config hasn't changed.
214
+ *no_deps* starts only *services*, not what they depend on.
215
+ """
216
+ cmd = ctx.base_cmd() + ["up"]
217
+ if detach:
218
+ cmd.append("-d")
219
+ if no_deps:
220
+ cmd.append("--no-deps")
221
+ if build:
222
+ cmd.append("--build")
223
+ if pull:
224
+ cmd.append(f"--pull={pull}")
225
+ if force_recreate:
226
+ cmd.append("--force-recreate")
227
+ if wait:
228
+ cmd.append("--wait")
229
+ if timeout > 0:
230
+ cmd.append(f"--wait-timeout={timeout}")
231
+ if services:
232
+ cmd.extend(services)
233
+ result = _run(cmd, stream=True)
234
+ return result.returncode
235
+
236
+
237
+ def service_names(ctx: ComposeContext) -> list[str]:
238
+ """The services this compose setup runs (its active profiles included); empty when compose cannot say."""
239
+ result = _run(ctx.base_cmd() + ["config", "--services"], capture=True)
240
+ if result.returncode != 0:
241
+ return []
242
+ return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
243
+
244
+
245
+ def down(
246
+ ctx: ComposeContext,
247
+ *,
248
+ volumes: bool = False,
249
+ remove_orphans: bool = False,
250
+ rmi: str | None = None,
251
+ ) -> int:
252
+ """Run ``docker compose down``.
253
+
254
+ Args:
255
+ rmi: Remove images. ``"all"`` removes all images, ``"local"``
256
+ removes only images without a custom tag.
257
+ """
258
+ cmd = ctx.base_cmd() + ["down"]
259
+ if volumes:
260
+ cmd.append("-v")
261
+ if remove_orphans:
262
+ cmd.append("--remove-orphans")
263
+ if rmi:
264
+ cmd.extend(["--rmi", rmi])
265
+ result = _run(cmd, stream=True)
266
+ return result.returncode
267
+
268
+
269
+ def restart(ctx: ComposeContext, *, services: list[str] | None = None) -> int:
270
+ """Run ``docker compose restart`` for the given services."""
271
+ cmd = ctx.base_cmd() + ["restart"]
272
+ if services:
273
+ cmd.extend(services)
274
+ result = _run(cmd, stream=True)
275
+ return result.returncode
276
+
277
+
278
+ def pull(ctx: ComposeContext, *, services: list[str] | None = None) -> int:
279
+ """Run ``docker compose pull``."""
280
+ cmd = ctx.base_cmd() + ["pull"]
281
+ if services:
282
+ cmd.extend(services)
283
+ result = _run(cmd, stream=True)
284
+ return result.returncode
285
+
286
+
287
+ def build(
288
+ ctx: ComposeContext,
289
+ *,
290
+ services: list[str] | None = None,
291
+ no_cache: bool = False,
292
+ pull: bool = False,
293
+ ) -> int:
294
+ """Run ``docker compose build``."""
295
+ cmd = ctx.base_cmd() + ["build"]
296
+ if no_cache:
297
+ cmd.append("--no-cache")
298
+ if pull:
299
+ cmd.append("--pull")
300
+ if services:
301
+ cmd.extend(services)
302
+ result = _run(cmd, stream=True)
303
+ return result.returncode
304
+
305
+
306
+ def logs(
307
+ ctx: ComposeContext,
308
+ *,
309
+ services: list[str] | None = None,
310
+ follow: bool = True,
311
+ tail: int | None = 100,
312
+ timestamps: bool = False,
313
+ ) -> int:
314
+ """Run ``docker compose logs``."""
315
+ cmd = ctx.base_cmd() + ["logs"]
316
+ if follow:
317
+ cmd.append("-f")
318
+ if tail is not None:
319
+ cmd.extend(["--tail", str(tail)])
320
+ if timestamps:
321
+ cmd.append("--timestamps")
322
+ if services:
323
+ cmd.extend(services)
324
+ result = _run(cmd, stream=True)
325
+ return result.returncode
326
+
327
+
328
+ def ps(ctx: ComposeContext) -> list[ComposeService]:
329
+ """Run ``docker compose ps`` and parse the output."""
330
+ import json as _json
331
+
332
+ cmd = ctx.base_cmd() + ["ps", "--format", "json", "-a"]
333
+ result = _run(cmd, capture=True)
334
+ if result.returncode != 0:
335
+ return []
336
+
337
+ services: list[ComposeService] = []
338
+ for line in result.stdout.strip().splitlines():
339
+ line = line.strip()
340
+ if not line:
341
+ continue
342
+ try:
343
+ obj = _json.loads(line)
344
+ svc = ComposeService(
345
+ name=obj.get("Name", ""),
346
+ service=obj.get("Service", ""),
347
+ status=obj.get("State", obj.get("Status", "")),
348
+ health=obj.get("Health", ""),
349
+ ports=obj.get("Publishers", obj.get("Ports", "")),
350
+ state=obj.get("State", ""),
351
+ exit_code=obj.get("ExitCode", 0),
352
+ )
353
+ # Format ports if it's a list of dicts
354
+ if isinstance(svc.ports, list):
355
+ port_strs = []
356
+ for p in svc.ports:
357
+ if isinstance(p, dict) and p.get("PublishedPort"):
358
+ port_strs.append(f"{p.get('URL', '0.0.0.0')}:{p['PublishedPort']}→{p['TargetPort']}")
359
+ svc.ports = ", ".join(port_strs) if port_strs else ""
360
+ services.append(svc)
361
+ except (ValueError, KeyError):
362
+ continue
363
+ return services
364
+
365
+
366
+ def foreign_containers(ctx: ComposeContext) -> list[tuple[str, str]]:
367
+ """Containers holding a name this compose file gives, made by something else.
368
+
369
+ Compose cannot replace a container it did not create: ``up`` stops with
370
+ "The container name ... is already in use". An upgrade met one -- a
371
+ Prometheus recreated by hand with ``docker run`` -- after building images
372
+ for a quarter of an hour. Returns ``(name, made by)`` pairs, where *made
373
+ by* is the other compose project, or ``""`` for a plain ``docker run``.
374
+ """
375
+ config = _run(ctx.base_cmd() + ["config", "--format", "json"], capture=True)
376
+ if config.returncode != 0:
377
+ return []
378
+ try:
379
+ spec = json.loads(config.stdout or "{}")
380
+ except ValueError:
381
+ return []
382
+ project = spec.get("name", "")
383
+ wanted = {
384
+ s.get("container_name")
385
+ for s in (spec.get("services") or {}).values()
386
+ if isinstance(s, dict) and s.get("container_name")
387
+ }
388
+ docker = shutil.which("docker") or "docker"
389
+ listed = _run(
390
+ [docker, "ps", "-a", "--format", '{{.Names}}\t{{.Label "com.docker.compose.project"}}'],
391
+ capture=True,
392
+ )
393
+ found = []
394
+ for line in (listed.stdout or "").splitlines():
395
+ name, _, owner = line.partition("\t")
396
+ if name in wanted and owner != project:
397
+ found.append((name, owner))
398
+ return sorted(found)
399
+
400
+
401
+ def exec_cmd(
402
+ ctx: ComposeContext,
403
+ service: str,
404
+ command: list[str],
405
+ *,
406
+ capture: bool = False,
407
+ ) -> subprocess.CompletedProcess[str]:
408
+ """Run ``docker compose exec`` on a running service."""
409
+ cmd = ctx.base_cmd() + ["exec", service, *command]
410
+ return _run(cmd, capture=capture)
411
+
412
+
413
+ def run_cmd(
414
+ ctx: ComposeContext,
415
+ service: str,
416
+ command: list[str],
417
+ *,
418
+ capture: bool = False,
419
+ remove: bool = True,
420
+ ) -> subprocess.CompletedProcess[str]:
421
+ """Run ``docker compose run`` (one-off container)."""
422
+ cmd = ctx.base_cmd() + ["run"]
423
+ if remove:
424
+ cmd.append("--rm")
425
+ cmd.extend([service, *command])
426
+ return _run(cmd, capture=capture)
427
+
428
+
429
+ def _resolve_compose_path(cfg: "FadenConfig") -> Path:
430
+ """Resolve the compose file, falling back to shared/."""
431
+ primary = cfg.compose_path
432
+ if primary.exists():
433
+ return primary
434
+ # Check inside shared relative to install path
435
+ for name in ("docker-compose.yaml", "docker-compose.yml"):
436
+ candidate = cfg.install_path / "shared" / name
437
+ if candidate.exists():
438
+ return candidate
439
+ return primary
440
+
441
+
442
+ def build_context_from_config(cfg: "FadenConfig", *, gpu: bool | None = None) -> ComposeContext:
443
+ """Build a ComposeContext from the current config.
444
+
445
+ *gpu* controls whether the GPU overlay is included:
446
+ - ``None`` (default): auto-detect via :func:`has_nvidia_gpu`
447
+ - ``True``: force-include the GPU overlay
448
+ - ``False``: never include the GPU overlay
449
+ """
450
+ compose_file = _resolve_compose_path(cfg)
451
+ files = [compose_file]
452
+
453
+ use_gpu = has_nvidia_gpu() if gpu is None else gpu
454
+ if use_gpu:
455
+ gpu_overlay = compose_file.parent / "docker-compose.gpu.yaml"
456
+ if gpu_overlay.exists():
457
+ files.append(gpu_overlay)
458
+
459
+ return ComposeContext(
460
+ compose_files=files,
461
+ env_file=cfg.env_path if cfg.env_path.exists() else None,
462
+ project_dir=compose_file.parent,
463
+ profiles=list(cfg.profiles),
464
+ )
465
+
466
+
467
+ def build_context_from_paths(
468
+ compose_file: Path,
469
+ *,
470
+ env_file: Path | None = None,
471
+ project_dir: Path | None = None,
472
+ profiles: list[str] | None = None,
473
+ dev_overlay: Path | None = None,
474
+ ) -> ComposeContext:
475
+ """Build a ComposeContext from explicit paths."""
476
+ files = [compose_file]
477
+ if dev_overlay and dev_overlay.exists():
478
+ files.append(dev_overlay)
479
+ return ComposeContext(
480
+ compose_files=files,
481
+ env_file=env_file,
482
+ project_dir=project_dir or compose_file.parent,
483
+ profiles=profiles or [],
484
+ )
@@ -0,0 +1,106 @@
1
+ """Rich console singleton and shared output helpers.
2
+
3
+ Provides a single ``Console`` instance used by every command so that
4
+ output is consistent (colours, width, Unicode support). Helper
5
+ functions wrap common patterns: success/warning/error panels, spinners,
6
+ tables, and key-value displays.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.theme import Theme
15
+
16
+ _THEME = Theme(
17
+ {
18
+ "info": "cyan",
19
+ "success": "bold green",
20
+ "warning": "bold yellow",
21
+ "error": "bold red",
22
+ "heading": "bold magenta",
23
+ "muted": "dim",
24
+ },
25
+ )
26
+
27
+ console = Console(theme=_THEME)
28
+
29
+
30
+ # ── Shorthand helpers ─────────────────────────────────────────────
31
+
32
+
33
+ def heading(text: str) -> None:
34
+ """Print a heading-styled line."""
35
+ console.print(f"\n[heading]{text}[/heading]")
36
+
37
+
38
+ def success(text: str) -> None:
39
+ """Print a success-styled line."""
40
+ console.print(f"[success]✓[/success] {text}")
41
+
42
+
43
+ def warning(text: str) -> None:
44
+ """Print a warning-styled line."""
45
+ console.print(f"[warning]⚠[/warning] {text}")
46
+
47
+
48
+ def error(text: str) -> None:
49
+ """Print an error-styled line."""
50
+ console.print(f"[error]✗[/error] {text}")
51
+
52
+
53
+ def info(text: str) -> None:
54
+ """Print an info-styled line."""
55
+ console.print(f"[info]ℹ[/info] {text}")
56
+
57
+
58
+ def kv_table(title: str, rows: list[tuple[str, str]]) -> None:
59
+ """Print a two-column key/value table."""
60
+ table = Table(title=title, show_header=False, border_style="dim")
61
+ table.add_column("Key", style="bold", min_width=20)
62
+ table.add_column("Value")
63
+ for key, value in rows:
64
+ table.add_row(key, value)
65
+ console.print(table)
66
+
67
+
68
+ def banner(lines: list[str], *, title: str = "fadenstack", border_style: str = "cyan") -> None:
69
+ """Print a bordered panel with the given lines."""
70
+ body = "\n".join(lines)
71
+ console.print(Panel(body, title=f"[bold]{title}[/bold]", border_style=border_style))
72
+
73
+
74
+ def service_table(rows: list[dict[str, str]], *, title: str = "Services") -> None:
75
+ """Print a services status table.
76
+
77
+ Each row dict should have keys: name, status, health, ports, uptime.
78
+ """
79
+ table = Table(title=title, border_style="dim")
80
+ table.add_column("Service", style="bold")
81
+ table.add_column("Status")
82
+ table.add_column("Health")
83
+ table.add_column("Ports", style="cyan")
84
+ table.add_column("Uptime", style="dim")
85
+
86
+ status_styles = {
87
+ "running": "green",
88
+ "exited": "red",
89
+ "created": "yellow",
90
+ "restarting": "yellow",
91
+ "paused": "yellow",
92
+ "removing": "red",
93
+ "dead": "red",
94
+ }
95
+
96
+ for row in rows:
97
+ status = row.get("status", "unknown")
98
+ style = status_styles.get(status, "white")
99
+ table.add_row(
100
+ row.get("name", ""),
101
+ f"[{style}]{status}[/{style}]",
102
+ row.get("health", ""),
103
+ row.get("ports", ""),
104
+ row.get("uptime", ""),
105
+ )
106
+ console.print(table)