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,283 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ """Progress steps interface for LocalArgo CLI UI."""
7
+
8
+ # pylint: disable=duplicate-code,too-many-public-methods,too-many-instance-attributes
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from contextlib import contextmanager, suppress
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from rich.console import Console
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
18
+
19
+ try:
20
+ from typing import Self
21
+ except ImportError:
22
+ from typing_extensions import Self
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Generator
26
+
27
+
28
+ class StepLogger:
29
+ """
30
+ Supports declarative multi-step progress flows.
31
+
32
+ Args:
33
+ steps (list[str]): List of step names to track
34
+ console (Console | None): Rich console instance for output
35
+ (optional, creates default if None)
36
+
37
+ Example:
38
+ steps = ["initialize", "create cluster", "wait for readiness", "configure kubecontext"]
39
+ with StepLogger(steps) as logger:
40
+ logger.step("initialize", status="success")
41
+ logger.step("create cluster", status="error", error_msg="timeout")
42
+ """
43
+
44
+ def __init__(self, steps: list[str], console: Console | None = None) -> None:
45
+ self.steps = steps
46
+ self.console = console or Console()
47
+ self.current_step_index = 0
48
+ self.start_time = time.time()
49
+ self._completed_steps: dict[str, dict[str, Any]] = {}
50
+
51
+ def __enter__(self) -> Self:
52
+ """Enter context manager, display initial progress."""
53
+ self.console.print(
54
+ f"\n[bold blue]Starting workflow with {len(self.steps)} steps...[/bold blue]\n"
55
+ )
56
+ return self
57
+
58
+ def _count_step_results(self) -> tuple[int, int, int]:
59
+ """Count completed steps by result type.
60
+
61
+ Returns:
62
+ tuple[int, int, int]: (success_count, warning_count, error_count)
63
+ """
64
+ success_count = self._count_where(
65
+ lambda si: si.get("success", False) and not si.get("warning", False)
66
+ )
67
+ warning_count = self._count_where(lambda si: si.get("warning", False))
68
+ error_count = self._count_where(lambda si: si.get("error", False))
69
+ return success_count, warning_count, error_count
70
+
71
+ def _count_where(self, predicate: Any) -> int:
72
+ """Count entries in completed steps where predicate returns True."""
73
+ return sum(1 for step_info in self._completed_steps.values() if predicate(step_info))
74
+
75
+ def _show_step_summary(self) -> None:
76
+ """Show a summary of all step results."""
77
+ if not self._completed_steps:
78
+ return
79
+
80
+ self.console.print("\n[bold]Step Summary:[/bold]")
81
+ for step_name in self.steps:
82
+ self._show_single_step_summary(step_name)
83
+
84
+ return
85
+
86
+ def _show_single_step_summary(self, step_name: str) -> None:
87
+ """Render a single step's summary line to the console."""
88
+ if step_name not in self._completed_steps:
89
+ self.console.print(f" [dim]⏳ {step_name} (not started)[/dim]")
90
+ return
91
+
92
+ step_info = self._completed_steps[step_name]
93
+ icon, style = self._summary_icon_and_style(step_info)
94
+
95
+ if step_info.get("timestamp"):
96
+ duration_val = step_info["timestamp"] - self.start_time
97
+ duration = f" ({duration_val:.1f}s)"
98
+ else:
99
+ duration = ""
100
+
101
+ self.console.print(f" [bold {style}]{icon} {step_name}[/bold {style}]{duration}")
102
+
103
+ @staticmethod
104
+ def _summary_icon_and_style(step_info: dict[str, Any]) -> tuple[str, str]:
105
+ """Return appropriate icon and style for the given step info."""
106
+ if step_info.get("success", False):
107
+ return "✅", "green"
108
+ if step_info.get("warning", False):
109
+ return "⚠️", "yellow"
110
+ return "❌", "red"
111
+
112
+ def __exit__(
113
+ self,
114
+ exc_type: type[BaseException] | None,
115
+ exc_val: BaseException | None,
116
+ exc_tb: object,
117
+ ) -> None:
118
+ """Exit context manager, show final summary."""
119
+ total_time = time.time() - self.start_time
120
+
121
+ # Count successes, warnings, and failures
122
+ success_count, warning_count, error_count = self._count_step_results()
123
+
124
+ if error_count == 0 and warning_count == 0:
125
+ self.console.print(
126
+ f"\n[green]✅ All {len(self.steps)} steps completed successfully "
127
+ f"in {total_time:.1f}s[/green]"
128
+ )
129
+ elif error_count == 0:
130
+ self.console.print(
131
+ f"\n[yellow]⚠️ {success_count}/{len(self.steps)} steps completed "
132
+ f"with {warning_count} warnings in {total_time:.1f}s[/yellow]"
133
+ )
134
+ else:
135
+ self.console.print(
136
+ f"\n[red]❌ {success_count}/{len(self.steps)} steps completed "
137
+ f"with {error_count} errors in {total_time:.1f}s[/red]"
138
+ )
139
+
140
+ # Show step summary if there were issues
141
+ if error_count > 0 or warning_count > 0:
142
+ self._show_step_summary()
143
+
144
+ def step(
145
+ self,
146
+ name: str,
147
+ status: str = "success",
148
+ error_msg: str | None = None,
149
+ **info: Any,
150
+ ) -> None:
151
+ """Log a step with success/failure status."""
152
+ if not _validate_step_exists(name, self.steps, self.console):
153
+ return
154
+
155
+ # Parse status and create step info
156
+ step_info = _create_step_info(status, error_msg, info)
157
+ self._completed_steps[name] = step_info
158
+
159
+ # Update current step index
160
+ _update_current_step_index(name, self.steps, self)
161
+
162
+ # Display step
163
+ _display_step_status(name, step_info, self.console)
164
+
165
+ def get_step_info(self, name: str) -> dict[str, Any] | None:
166
+ """Get information about a specific step."""
167
+ return self._completed_steps.get(name)
168
+
169
+ def is_completed(self, name: str) -> bool:
170
+ """Check if a step has been completed."""
171
+ return name in self._completed_steps
172
+
173
+ def get_completed_steps_count(self) -> int:
174
+ """Get the number of completed steps."""
175
+ return len(self._completed_steps)
176
+
177
+ def get_success_count(self) -> int:
178
+ """Get count of successful steps."""
179
+ success_count, _, _ = self._count_step_results()
180
+ return success_count
181
+
182
+ def get_error_count(self) -> int:
183
+ """Get count of failed steps."""
184
+ _, _, error_count = self._count_step_results()
185
+ return error_count
186
+
187
+ @contextmanager
188
+ def step_with_progress(
189
+ self,
190
+ name: str,
191
+ total: int = 100,
192
+ description: str | None = None,
193
+ ) -> Generator[Progress, None, None]:
194
+ """Context manager for steps that need progress indication."""
195
+ if name not in self.steps:
196
+ self.console.print(f"[red]⚠️ Unknown step: {name}[/red]")
197
+ # Return a dummy progress object that does nothing
198
+ yield Progress()
199
+ return
200
+
201
+ with Progress(
202
+ SpinnerColumn(),
203
+ TextColumn("[progress.description]{task.description}"),
204
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
205
+ TimeElapsedColumn(),
206
+ console=self.console,
207
+ transient=True,
208
+ ) as progress:
209
+ progress.add_task(description or f"Processing {name}...", total=total)
210
+
211
+ try:
212
+ yield progress
213
+
214
+ # Mark step as completed
215
+ self.step(name, status="success", progress_info=f"Completed {total} items")
216
+ except Exception as e:
217
+ # Mark step as failed
218
+ self.step(name, status="error", error_msg=str(e))
219
+ raise
220
+
221
+ return
222
+
223
+
224
+ def _validate_step_exists(name: str, steps: list[str], console: Console) -> bool:
225
+ """Validate that a step exists in the steps list."""
226
+ if name not in steps:
227
+ console.print(f"[red]⚠️ Unknown step: {name}[/red]")
228
+ return False
229
+ return True
230
+
231
+
232
+ def _create_step_info(
233
+ status: str, error_msg: str | None, info: dict[str, Any]
234
+ ) -> dict[str, Any]:
235
+ """Create step info dictionary from status and additional data."""
236
+ success = status == "success"
237
+ warning = status == "warning"
238
+ error = status == "error"
239
+
240
+ return {
241
+ "success": success,
242
+ "warning": warning,
243
+ "error": error,
244
+ "error_msg": error_msg,
245
+ "info": info,
246
+ "timestamp": time.time(),
247
+ }
248
+
249
+
250
+ def _update_current_step_index(
251
+ name: str, steps: list[str], progress_logger: StepLogger
252
+ ) -> None:
253
+ """Update the current step index."""
254
+ with suppress(ValueError):
255
+ progress_logger.current_step_index = steps.index(name)
256
+
257
+
258
+ def _display_step_status(name: str, step_info: dict[str, Any], console: Console) -> None:
259
+ """Display the step status with appropriate styling."""
260
+ icon, style = _get_step_display_info(step_info)
261
+
262
+ # Format step display
263
+ step_display = f"[bold {style}]{icon} {name}[/bold {style}]"
264
+
265
+ if step_info.get("error_msg"):
266
+ console.print(f"{step_display} - {step_info['error_msg']}")
267
+ else:
268
+ console.print(step_display)
269
+
270
+ # Show additional info if provided
271
+ info = step_info.get("info", {})
272
+ if info:
273
+ info_text = ", ".join(f"{k}={v}" for k, v in info.items())
274
+ console.print(f" [dim]{info_text}[/dim]")
275
+
276
+
277
+ def _get_step_display_info(step_info: dict[str, Any]) -> tuple[str, str]:
278
+ """Get the icon and style for step display."""
279
+ if step_info.get("success"):
280
+ return "✅", "green"
281
+ if step_info.get("warning"):
282
+ return "⚠️", "yellow"
283
+ return "❌", "red"
@@ -0,0 +1,154 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ """Table renderer interface for LocalArgo CLI UI."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from typing import Any
12
+
13
+ from rich.columns import Columns
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+
20
+ class TableRenderer:
21
+ """
22
+ Declarative interface to render tabular data.
23
+
24
+ Args:
25
+ console (Console | None): Rich console instance for output
26
+ (optional, creates default if None)
27
+
28
+ Example: status list, key-value display.
29
+ """
30
+
31
+ def __init__(self, console: Console | None = None) -> None:
32
+ self.console = console or Console()
33
+
34
+ def render_list(self, headers: list[str], rows: list[dict[str, Any]]) -> None:
35
+ """Render a list of rows given headers."""
36
+ if not rows:
37
+ self.console.print("[dim]No data to display[/dim]")
38
+ return
39
+
40
+ table = Table(show_header=True, header_style="bold blue", box=None)
41
+
42
+ # Add columns
43
+ for header in headers:
44
+ table.add_column(header, style="cyan", no_wrap=True)
45
+
46
+ # Add rows
47
+ for row in rows:
48
+ table.add_row(*[str(row.get(header, "")) for header in headers])
49
+
50
+ self.console.print(table)
51
+
52
+ def render_key_values(self, title: str, data: dict[str, Any]) -> None:
53
+ """Render a key/value panel view."""
54
+
55
+ # Create a table for key-value pairs
56
+ table = Table(show_header=False, box=None, pad_edge=False)
57
+
58
+ # Add key-value pairs as rows
59
+ for key, value in data.items():
60
+ table.add_row(Text(key, style="bold cyan"), Text(str(value), style="white"))
61
+
62
+ panel = Panel(
63
+ table, title=f"[bold blue]{title}[/bold blue]", border_style="blue", padding=(1, 2)
64
+ )
65
+
66
+ self.console.print(panel)
67
+
68
+ def _get_status_style(self, status: str) -> str:
69
+ """Get style for status text."""
70
+ status_lower = status.lower()
71
+ if status_lower in ["ready", "running", "healthy"]:
72
+ return "green"
73
+ if status_lower in ["pending", "starting"]:
74
+ return "yellow"
75
+ if status_lower in ["failed", "error", "stopped"]:
76
+ return "red"
77
+ return "white"
78
+
79
+ def _add_table_columns(self, table: Table, sample: dict[str, Any]) -> None:
80
+ """Add columns to table based on sample data."""
81
+ terminal_width = shutil.get_terminal_size().columns
82
+
83
+ if "name" in sample:
84
+ table.add_column("Name", style="cyan", no_wrap=True)
85
+ if "provider" in sample:
86
+ table.add_column("Provider", style="green")
87
+ if "status" in sample:
88
+ table.add_column("Status", style="yellow")
89
+ if "context" in sample:
90
+ table.add_column("Context", style="blue", max_width=min(30, terminal_width // 4))
91
+
92
+ def _create_table_row(self, cluster: dict[str, Any]) -> list[Text]:
93
+ """Create a styled table row for a cluster."""
94
+ row_data = []
95
+ styles = []
96
+
97
+ if "name" in cluster:
98
+ row_data.append(cluster["name"])
99
+ styles.append("cyan")
100
+
101
+ if "provider" in cluster:
102
+ row_data.append(cluster.get("provider", "unknown"))
103
+ styles.append("green")
104
+
105
+ if "status" in cluster:
106
+ status = cluster["status"]
107
+ row_data.append(status)
108
+ styles.append(self._get_status_style(status))
109
+
110
+ if "context" in cluster:
111
+ context = cluster.get("context", "")
112
+ row_data.append(context)
113
+ styles.append("blue")
114
+
115
+ return [Text(str(item), style=styles[i]) for i, item in enumerate(row_data)]
116
+
117
+ def render_status_table(self, clusters: list[dict[str, Any]]) -> None:
118
+ """Render cluster status in a table format."""
119
+ if not clusters:
120
+ self.console.print("[dim]No clusters to display[/dim]")
121
+ return
122
+
123
+ # Create status table
124
+ table = Table(title="Cluster Status", show_header=True, header_style="bold magenta")
125
+
126
+ # Add columns based on available data
127
+ self._add_table_columns(table, clusters[0])
128
+
129
+ # Add rows
130
+ for cluster in clusters:
131
+ styled_row = self._create_table_row(cluster)
132
+ table.add_row(*styled_row)
133
+
134
+ self.console.print(table)
135
+
136
+ def render_simple_list(self, items: list[str], title: str | None = None) -> None:
137
+ """Render a simple list of items."""
138
+ if not items:
139
+ self.console.print("[dim]No items to display[/dim]")
140
+ return
141
+
142
+ # Create a simple list
143
+
144
+ rendered_items = [Text(f"• {item}", style="white") for item in items]
145
+
146
+ if title:
147
+ panel = Panel(
148
+ Columns(rendered_items, equal=False, expand=True),
149
+ title=f"[bold blue]{title}[/bold blue]",
150
+ border_style="blue",
151
+ )
152
+ self.console.print(panel)
153
+ else:
154
+ self.console.print(Columns(rendered_items, equal=False, expand=True))
@@ -0,0 +1,57 @@
1
+ """Tables for app list/status with color-coded Health and Sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from localargo.eyecandy.table_renderer import TableRenderer
11
+
12
+
13
+ def _style_health(val: str) -> str:
14
+ v = (val or "").lower()
15
+ if v == "healthy":
16
+ return "green"
17
+ if v in ("progressing", "unknown"):
18
+ return "yellow"
19
+ if v == "degraded":
20
+ return "red"
21
+ return "white"
22
+
23
+
24
+ def _style_sync(val: str) -> str:
25
+ v = (val or "").lower()
26
+ if v == "synced":
27
+ return "green"
28
+ if v == "outofsync":
29
+ return "red"
30
+ return "white"
31
+
32
+
33
+ class AppTables(TableRenderer):
34
+ """Render color-coded app state tables using Rich."""
35
+
36
+ def render_app_states(self, states: list[dict[str, Any]]) -> None:
37
+ """Render a table of app states with styled Health/Sync columns."""
38
+ if not states:
39
+ self.console.print("[dim]No apps found[/dim]")
40
+ return
41
+ table = Table(show_header=True, header_style="bold blue", box=None)
42
+ table.add_column("Name", style="cyan", no_wrap=True)
43
+ table.add_column("Namespace", style="blue")
44
+ table.add_column("Health", style="white")
45
+ table.add_column("Sync", style="white")
46
+ table.add_column("Revision", style="white")
47
+ for st in states:
48
+ table.add_row(
49
+ Text(str(st.get("Name", "")), style="cyan"),
50
+ Text(str(st.get("Namespace", "")), style="blue"),
51
+ Text(
52
+ str(st.get("Health", "")), style=_style_health(str(st.get("Health", "")))
53
+ ),
54
+ Text(str(st.get("Sync", "")), style=_style_sync(str(st.get("Sync", "")))),
55
+ Text(str(st.get("Revision", "")), style="white"),
56
+ )
57
+ self.console.print(table)
localargo/logging.py ADDED
@@ -0,0 +1,99 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Logging configuration with rich handler for localargo."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+
11
+ from rich.console import Console
12
+ from rich.logging import RichHandler
13
+
14
+
15
+ def setup_logging(
16
+ level: str = "INFO",
17
+ *,
18
+ show_time: bool = True,
19
+ show_path: bool = False,
20
+ rich_tracebacks: bool = True,
21
+ ) -> logging.Logger:
22
+ """Set up logging with rich handler.
23
+
24
+ Args:
25
+ level (str): Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
26
+ show_time (bool): Whether to show timestamps
27
+ show_path (bool): Whether to show file paths
28
+ rich_tracebacks (bool): Whether to use rich tracebacks
29
+
30
+ Returns:
31
+ logging.Logger: Configured logger instance
32
+ """
33
+ # Create console for rich output
34
+ console = Console(stderr=True)
35
+
36
+ # Configure rich handler
37
+ rich_handler = RichHandler(
38
+ console=console,
39
+ show_time=show_time,
40
+ show_path=show_path,
41
+ enable_link_path=False,
42
+ markup=True,
43
+ rich_tracebacks=rich_tracebacks,
44
+ tracebacks_show_locals=False,
45
+ )
46
+
47
+ # Set log level
48
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
49
+ rich_handler.setLevel(numeric_level)
50
+
51
+ # Configure root logger
52
+ logging.basicConfig(
53
+ level=numeric_level,
54
+ format="%(message)s", # Rich handler handles formatting
55
+ handlers=[rich_handler],
56
+ force=True, # Override any existing configuration
57
+ )
58
+
59
+ # Create and return logger
60
+ localargo_logger = logging.getLogger("localargo")
61
+ localargo_logger.setLevel(numeric_level)
62
+
63
+ return localargo_logger
64
+
65
+
66
+ def get_logger(name: str | None = None) -> logging.Logger:
67
+ """Get a logger instance.
68
+
69
+ Args:
70
+ name (str | None): Logger name (defaults to 'localargo')
71
+
72
+ Returns:
73
+ logging.Logger: Logger instance
74
+ """
75
+ if name is None:
76
+ name = "localargo"
77
+ return logging.getLogger(name)
78
+
79
+
80
+ # Global logger instance
81
+ logger = get_logger()
82
+
83
+
84
+ def init_cli_logging(*, verbose: bool = False) -> logging.Logger:
85
+ """Initialize logging for CLI usage.
86
+
87
+ Args:
88
+ verbose (bool): Enable debug logging
89
+
90
+ Returns:
91
+ logging.Logger: Configured logger
92
+ """
93
+ # Check environment variable first, then verbose flag
94
+ env_level = os.getenv("LOCALARGO_LOG_LEVEL", "").upper()
95
+ if env_level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
96
+ level = env_level
97
+ else:
98
+ level = "DEBUG" if verbose else "INFO"
99
+ return setup_logging(level=level)