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.
- localargo/__about__.py +6 -0
- localargo/__init__.py +6 -0
- localargo/__main__.py +11 -0
- localargo/cli/__init__.py +49 -0
- localargo/cli/commands/__init__.py +5 -0
- localargo/cli/commands/app.py +150 -0
- localargo/cli/commands/cluster.py +312 -0
- localargo/cli/commands/debug.py +478 -0
- localargo/cli/commands/port_forward.py +311 -0
- localargo/cli/commands/secrets.py +300 -0
- localargo/cli/commands/sync.py +291 -0
- localargo/cli/commands/template.py +288 -0
- localargo/cli/commands/up.py +341 -0
- localargo/config/__init__.py +15 -0
- localargo/config/manifest.py +520 -0
- localargo/config/store.py +66 -0
- localargo/core/__init__.py +6 -0
- localargo/core/apps.py +330 -0
- localargo/core/argocd.py +509 -0
- localargo/core/catalog.py +284 -0
- localargo/core/cluster.py +149 -0
- localargo/core/k8s.py +140 -0
- localargo/eyecandy/__init__.py +15 -0
- localargo/eyecandy/progress_steps.py +283 -0
- localargo/eyecandy/table_renderer.py +154 -0
- localargo/eyecandy/tables.py +57 -0
- localargo/logging.py +99 -0
- localargo/manager.py +232 -0
- localargo/providers/__init__.py +6 -0
- localargo/providers/base.py +146 -0
- localargo/providers/k3s.py +206 -0
- localargo/providers/kind.py +326 -0
- localargo/providers/registry.py +52 -0
- localargo/utils/__init__.py +4 -0
- localargo/utils/cli.py +231 -0
- localargo/utils/proc.py +148 -0
- localargo/utils/retry.py +58 -0
- localargo-0.1.0.dist-info/METADATA +149 -0
- localargo-0.1.0.dist-info/RECORD +42 -0
- localargo-0.1.0.dist-info/WHEEL +4 -0
- localargo-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|