Coverage for cli / output.py: 97%
109 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 21:47 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 21:47 +0000
1"""
2Output formatting for GCO CLI.
4Provides consistent output formatting across all CLI commands
5with support for table, JSON, and YAML formats.
6"""
8import json
9import sys
10from dataclasses import asdict, is_dataclass
11from datetime import datetime
12from typing import Any
14import yaml
16from .config import GCOConfig, get_config
19def _serialize_value(value: Any) -> Any:
20 """Serialize a value for output."""
21 if isinstance(value, datetime):
22 return value.isoformat()
23 if is_dataclass(value) and not isinstance(value, type):
24 return asdict(value)
25 if isinstance(value, dict):
26 return {k: _serialize_value(v) for k, v in value.items()}
27 if isinstance(value, list):
28 return [_serialize_value(v) for v in value]
29 return value
32class OutputFormatter:
33 """
34 Formats output for CLI commands.
36 Supports:
37 - Table format (human-readable)
38 - JSON format (machine-readable)
39 - YAML format (configuration-friendly)
40 """
42 def __init__(self, config: GCOConfig | None = None):
43 self.config = config or get_config()
44 self._format = self.config.output_format
46 def set_format(self, format_type: str) -> None:
47 """Set the output format."""
48 if format_type not in ("table", "json", "yaml"):
49 raise ValueError(f"Invalid format: {format_type}")
50 self._format = format_type
52 def format(self, data: Any, columns: list[str] | None = None) -> str:
53 """
54 Format data for output.
56 Args:
57 data: Data to format (dict, list, or dataclass)
58 columns: Column names for table format
60 Returns:
61 Formatted string
62 """
63 if self._format == "json":
64 return self._format_json(data)
65 if self._format == "yaml":
66 return self._format_yaml(data)
67 return self._format_table(data, columns)
69 def _format_json(self, data: Any) -> str:
70 """Format data as JSON."""
71 serialized = _serialize_value(data)
72 return json.dumps(serialized, indent=2, default=str)
74 def _format_yaml(self, data: Any) -> str:
75 """Format data as YAML."""
76 serialized = _serialize_value(data)
77 return str(yaml.dump(serialized, default_flow_style=False, sort_keys=False))
79 def _format_table(self, data: Any, columns: list[str] | None = None) -> str:
80 """Format data as a table."""
81 if data is None:
82 return "No data"
84 # Convert to list of dicts
85 if is_dataclass(data) and not isinstance(data, type):
86 rows = [asdict(data)]
87 elif isinstance(data, dict):
88 rows = [data]
89 elif isinstance(data, list): 89 ↛ 100line 89 didn't jump to line 100 because the condition on line 89 was always true
90 if not data:
91 return "No results"
92 if is_dataclass(data[0]) and not isinstance(data[0], type): 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 rows = [asdict(item) for item in data]
94 elif isinstance(data[0], dict):
95 rows = data
96 else:
97 # Simple list
98 return "\n".join(str(item) for item in data)
99 else:
100 return str(data)
102 # Determine columns
103 if columns is None:
104 columns = list(rows[0].keys()) if rows else []
106 # Filter to only requested columns
107 rows = [{k: v for k, v in row.items() if k in columns} for row in rows]
109 # Calculate column widths
110 widths = {}
111 for col in columns:
112 col_values = [str(row.get(col, "")) for row in rows]
113 widths[col] = max(len(col), max(len(v) for v in col_values) if col_values else 0)
115 # Build table
116 lines = []
118 # Header
119 header = " ".join(col.upper().ljust(widths[col]) for col in columns)
120 lines.append(header)
121 lines.append("-" * len(header))
123 # Rows
124 for row in rows:
125 line = " ".join(
126 self._format_cell(row.get(col, ""), widths[col], col) for col in columns
127 )
128 lines.append(line)
130 return "\n".join(lines)
132 def _format_cell(self, value: Any, width: int, column_name: str = "") -> str:
133 """Format a single cell value."""
134 if value is None:
135 return "-".ljust(width)
136 if isinstance(value, datetime):
137 return value.strftime("%Y-%m-%d %H:%M").ljust(width)
138 if isinstance(value, bool):
139 return ("Yes" if value else "No").ljust(width)
140 if isinstance(value, float):
141 # Add dollar sign for price columns (but not stability/ratio columns)
142 col_lower = column_name.lower()
143 if "price" in col_lower and "stability" not in col_lower:
144 return f"${value:.4f}".ljust(width)
145 return f"{value:.4f}".ljust(width)
146 if isinstance(value, dict):
147 return "<dict>".ljust(width)
148 if isinstance(value, list):
149 return f"[{len(value)} items]".ljust(width)
150 return str(str(value)[:width]).ljust(width)
152 def print(self, data: Any, columns: list[str] | None = None) -> None:
153 """Format and print data."""
154 print(self.format(data, columns))
156 def print_success(self, message: str) -> None:
157 """Print a success message."""
158 print(f"✓ {message}")
160 def print_error(self, message: str) -> None:
161 """Print an error message."""
162 print(f"✗ {message}", file=sys.stderr)
164 def print_warning(self, message: str) -> None:
165 """Print a warning message."""
166 print(f"⚠ {message}", file=sys.stderr)
168 def print_info(self, message: str) -> None:
169 """Print an info message."""
170 print(f"ℹ {message}")
173# Convenience functions for common output patterns
176def format_job_table(jobs: list[Any]) -> str:
177 """Format jobs as a table."""
178 formatter = OutputFormatter()
179 return formatter.format(
180 jobs,
181 columns=[
182 "name",
183 "namespace",
184 "region",
185 "status",
186 "active_pods",
187 "succeeded_pods",
188 "failed_pods",
189 ],
190 )
193def format_capacity_table(estimates: list[Any]) -> str:
194 """Format capacity estimates as a table."""
195 formatter = OutputFormatter()
196 return formatter.format(
197 estimates,
198 columns=[
199 "instance_type",
200 "region",
201 "availability_zone",
202 "capacity_type",
203 "availability",
204 "price_per_hour",
205 "recommendation",
206 ],
207 )
210def format_file_system_table(file_systems: list[Any]) -> str:
211 """Format file systems as a table."""
212 formatter = OutputFormatter()
213 return formatter.format(
214 file_systems, columns=["file_system_id", "file_system_type", "region", "status", "dns_name"]
215 )
218def format_stack_table(stacks: list[Any]) -> str:
219 """Format regional stacks as a table."""
220 formatter = OutputFormatter()
221 return formatter.format(
222 stacks, columns=["region", "stack_name", "cluster_name", "status", "efs_file_system_id"]
223 )
226def get_output_formatter(config: GCOConfig | None = None) -> OutputFormatter:
227 """Get a configured output formatter instance."""
228 return OutputFormatter(config)