Coverage for cli / output.py: 97%

109 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-30 21:47 +0000

1""" 

2Output formatting for GCO CLI. 

3 

4Provides consistent output formatting across all CLI commands 

5with support for table, JSON, and YAML formats. 

6""" 

7 

8import json 

9import sys 

10from dataclasses import asdict, is_dataclass 

11from datetime import datetime 

12from typing import Any 

13 

14import yaml 

15 

16from .config import GCOConfig, get_config 

17 

18 

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 

30 

31 

32class OutputFormatter: 

33 """ 

34 Formats output for CLI commands. 

35 

36 Supports: 

37 - Table format (human-readable) 

38 - JSON format (machine-readable) 

39 - YAML format (configuration-friendly) 

40 """ 

41 

42 def __init__(self, config: GCOConfig | None = None): 

43 self.config = config or get_config() 

44 self._format = self.config.output_format 

45 

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 

51 

52 def format(self, data: Any, columns: list[str] | None = None) -> str: 

53 """ 

54 Format data for output. 

55 

56 Args: 

57 data: Data to format (dict, list, or dataclass) 

58 columns: Column names for table format 

59 

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) 

68 

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) 

73 

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)) 

78 

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" 

83 

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) 

101 

102 # Determine columns 

103 if columns is None: 

104 columns = list(rows[0].keys()) if rows else [] 

105 

106 # Filter to only requested columns 

107 rows = [{k: v for k, v in row.items() if k in columns} for row in rows] 

108 

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) 

114 

115 # Build table 

116 lines = [] 

117 

118 # Header 

119 header = " ".join(col.upper().ljust(widths[col]) for col in columns) 

120 lines.append(header) 

121 lines.append("-" * len(header)) 

122 

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) 

129 

130 return "\n".join(lines) 

131 

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) 

151 

152 def print(self, data: Any, columns: list[str] | None = None) -> None: 

153 """Format and print data.""" 

154 print(self.format(data, columns)) 

155 

156 def print_success(self, message: str) -> None: 

157 """Print a success message.""" 

158 print(f"{message}") 

159 

160 def print_error(self, message: str) -> None: 

161 """Print an error message.""" 

162 print(f"{message}", file=sys.stderr) 

163 

164 def print_warning(self, message: str) -> None: 

165 """Print a warning message.""" 

166 print(f"{message}", file=sys.stderr) 

167 

168 def print_info(self, message: str) -> None: 

169 """Print an info message.""" 

170 print(f"{message}") 

171 

172 

173# Convenience functions for common output patterns 

174 

175 

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 ) 

191 

192 

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 ) 

208 

209 

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 ) 

216 

217 

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 ) 

224 

225 

226def get_output_formatter(config: GCOConfig | None = None) -> OutputFormatter: 

227 """Get a configured output formatter instance.""" 

228 return OutputFormatter(config)