Coverage for session_buddy / utils / messages.py: 33.59%

93 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-04 00:43 -0800

1#!/usr/bin/env python3 

2"""Message formatting utilities for MCP tools. 

3 

4This module provides consistent message formatting across all tool implementations, 

5eliminating duplication and ensuring uniform user experience. 

6""" 

7 

8from __future__ import annotations 

9 

10from datetime import datetime 

11from typing import Any 

12 

13 

14class ToolMessages: 

15 """Centralized tool message formatting. 

16 

17 This class provides static methods for formatting common tool messages 

18 with consistent styling and structure. 

19 """ 

20 

21 @staticmethod 

22 def not_available(feature: str, install_hint: str = "") -> str: 

23 """Format feature unavailable message. 

24 

25 Args: 

26 feature: Name of the unavailable feature 

27 install_hint: Optional installation instructions 

28 

29 Returns: 

30 Formatted error message 

31 

32 Example: 

33 >>> ToolMessages.not_available("Database", "uv sync --extra embeddings") 

34 '❌ Database not available. Install: uv sync --extra embeddings' 

35 

36 """ 

37 msg = f"{feature} not available" 

38 if install_hint: 38 ↛ 43line 38 didn't jump to line 43 because the condition on line 38 was always true

39 if not install_hint.startswith("Install"): 39 ↛ 42line 39 didn't jump to line 42 because the condition on line 39 was always true

40 msg += f". Install: {install_hint}" 

41 else: 

42 msg += f". {install_hint}" 

43 return msg 

44 

45 @staticmethod 

46 def operation_failed(operation: str, error: Exception | str) -> str: 

47 """Format operation failure message. 

48 

49 Args: 

50 operation: Name of the failed operation 

51 error: Exception or error message 

52 

53 Returns: 

54 Formatted error message 

55 

56 Example: 

57 >>> ToolMessages.operation_failed("Search", ValueError("Bad input")) 

58 '❌ Search failed: Bad input' 

59 

60 """ 

61 error_str = str(error) 

62 # Remove "Exception: " prefix if present 

63 if ": " in error_str and error_str.split(": ", maxsplit=1)[0].endswith("Error"): 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 error_str = ": ".join(error_str.split(": ")[1:]) 

65 return f"{operation} failed: {error_str}" 

66 

67 @staticmethod 

68 def success(message: str, details: dict[str, Any] | None = None) -> str: 

69 """Format success message with optional details. 

70 

71 Args: 

72 message: Main success message 

73 details: Optional dictionary of details to display 

74 

75 Returns: 

76 Formatted success message with details 

77 

78 Example: 

79 >>> ToolMessages.success("Stored", {"items": 5, "time": "1.2s"}) 

80 '✅ Stored 

81 • items: 5 

82 • time: 1.2s' 

83 

84 """ 

85 lines = [f"{message}"] 

86 if details: 

87 for key, value in details.items(): 

88 lines.append(f"{key}: {value}") 

89 return "\n".join(lines) 

90 

91 @staticmethod 

92 def validation_error(field: str, message: str) -> str: 

93 """Format input validation error. 

94 

95 Args: 

96 field: Name of the invalid field 

97 message: Validation error description 

98 

99 Returns: 

100 Formatted validation error 

101 

102 Example: 

103 >>> ToolMessages.validation_error("email", "Invalid format") 

104 '❌ Validation error: email - Invalid format' 

105 

106 """ 

107 return f"❌ Validation error: {field} - {message}" 

108 

109 @staticmethod 

110 def empty_results(operation: str, suggestion: str = "") -> str: 

111 """Format message for empty results. 

112 

113 Args: 

114 operation: Operation that returned no results 

115 suggestion: Optional suggestion for the user 

116 

117 Returns: 

118 Formatted empty results message 

119 

120 Example: 

121 >>> ToolMessages.empty_results("Search", "Try broader terms") 

122 'i️ No results found for Search. Try broader terms' 

123 

124 """ 

125 msg = f"ℹ️ No results found for {operation}" 

126 if suggestion: 

127 msg += f". {suggestion}" 

128 return msg 

129 

130 @staticmethod 

131 def format_list_item(emoji: str, label: str, value: Any) -> str: 

132 """Format a single list item with emoji and label. 

133 

134 Args: 

135 emoji: Emoji to use for the item 

136 label: Label for the item 

137 value: Value to display 

138 

139 Returns: 

140 Formatted list item 

141 

142 Example: 

143 >>> ToolMessages.format_list_item("📝", "Content", "Hello world") 

144 '📝 Content: Hello world' 

145 

146 """ 

147 return f"{emoji} {label}: {value}" 

148 

149 @staticmethod 

150 def format_timestamp(dt: datetime | None = None) -> str: 

151 """Format a timestamp consistently. 

152 

153 Args: 

154 dt: Datetime to format (defaults to now) 

155 

156 Returns: 

157 Formatted timestamp string 

158 

159 Example: 

160 >>> ToolMessages.format_timestamp() 

161 '2025-01-12 14:30:45' 

162 

163 """ 

164 if dt is None: 

165 dt = datetime.now() 

166 return dt.strftime("%Y-%m-%d %H:%M:%S") 

167 

168 @staticmethod 

169 def format_count(count: int, singular: str, plural: str | None = None) -> str: 

170 """Format a count with appropriate singular/plural form. 

171 

172 Args: 

173 count: Number to format 

174 singular: Singular form of the noun 

175 plural: Plural form (defaults to singular + 's') 

176 

177 Returns: 

178 Formatted count string 

179 

180 Example: 

181 >>> ToolMessages.format_count(1, "result") 

182 '1 result' 

183 >>> ToolMessages.format_count(5, "match", "matches") 

184 '5 matches' 

185 

186 """ 

187 if plural is None: 

188 plural = f"{singular}s" 

189 word = singular if count == 1 else plural 

190 return f"{count} {word}" 

191 

192 @staticmethod 

193 def format_progress(current: int, total: int, operation: str = "") -> str: 

194 """Format a progress indicator. 

195 

196 Args: 

197 current: Current progress 

198 total: Total items 

199 operation: Optional operation description 

200 

201 Returns: 

202 Formatted progress string 

203 

204 Example: 

205 >>> ToolMessages.format_progress(5, 10, "Processing") 

206 'Processing: 5/10 (50%)' 

207 

208 """ 

209 percentage = int((current / total) * 100) if total > 0 else 0 

210 base = f"{current}/{total} ({percentage}%)" 

211 if operation: 

212 return f"{operation}: {base}" 

213 return base 

214 

215 @staticmethod 

216 def format_duration(seconds: float) -> str: 

217 """Format a duration in seconds to human-readable form. 

218 

219 Args: 

220 seconds: Duration in seconds 

221 

222 Returns: 

223 Formatted duration string 

224 

225 Example: 

226 >>> ToolMessages.format_duration(65.5) 

227 '1m 5.5s' 

228 >>> ToolMessages.format_duration(3.2) 

229 '3.2s' 

230 

231 """ 

232 if seconds < 60: 

233 return f"{seconds:.1f}s" 

234 minutes = int(seconds // 60) 

235 remaining_seconds = seconds % 60 

236 return f"{minutes}m {remaining_seconds:.1f}s" 

237 

238 @staticmethod 

239 def format_bytes(bytes_count: int) -> str: 

240 """Format byte count to human-readable form. 

241 

242 Args: 

243 bytes_count: Number of bytes 

244 

245 Returns: 

246 Formatted byte string with appropriate unit 

247 

248 Example: 

249 >>> ToolMessages.format_bytes(1500) 

250 '1.5 KB' 

251 >>> ToolMessages.format_bytes(1_500_000) 

252 '1.4 MB' 

253 

254 """ 

255 bytes_float = float(bytes_count) 

256 for unit in ("B", "KB", "MB", "GB"): 

257 if bytes_float < 1024.0: 

258 return f"{bytes_float:.1f} {unit}" 

259 bytes_float /= 1024.0 

260 return f"{bytes_float:.1f} TB" 

261 

262 @staticmethod 

263 def format_result_summary( 

264 results: list[Any], 

265 operation: str, 

266 show_count: bool = True, 

267 max_display: int = 5, 

268 ) -> str: 

269 """Format a summary of results from an operation. 

270 

271 Args: 

272 results: List of results 

273 operation: Operation that produced results 

274 show_count: Whether to show result count 

275 max_display: Maximum number of results to show details for 

276 

277 Returns: 

278 Formatted result summary 

279 

280 Example: 

281 >>> results = ["a", "b", "c"] 

282 >>> ToolMessages.format_result_summary(results, "Search") 

283 '✅ Search complete: 3 results' 

284 

285 """ 

286 count = len(results) 

287 

288 if count == 0: 

289 return ToolMessages.empty_results(operation) 

290 

291 lines = [] 

292 if show_count: 

293 count_str = ToolMessages.format_count(count, "result") 

294 lines.append(f"{operation} complete: {count_str}") 

295 else: 

296 lines.append(f"{operation} complete") 

297 

298 # Show summary of first few results if they're simple types 

299 if max_display > 0 and results: 

300 sample = results[:max_display] 

301 for i, result in enumerate(sample, 1): 

302 if isinstance(result, (str, int, float, bool)): 

303 lines.append(f" {i}. {result}") 

304 

305 if count > max_display: 

306 lines.append(f" ... and {count - max_display} more") 

307 

308 return "\n".join(lines) 

309 

310 @staticmethod 

311 def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str: 

312 """Truncate text to maximum length with suffix. 

313 

314 Args: 

315 text: Text to truncate 

316 max_length: Maximum length before truncation 

317 suffix: Suffix to add when truncated 

318 

319 Returns: 

320 Truncated text with suffix if needed 

321 

322 Example: 

323 >>> ToolMessages.truncate_text("Hello world this is long", 15) 

324 'Hello world ...' 

325 

326 """ 

327 if len(text) <= max_length: 327 ↛ 329line 327 didn't jump to line 329 because the condition on line 327 was always true

328 return text 

329 return text[: max_length - len(suffix)] + suffix