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
« 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.
4This module provides consistent message formatting across all tool implementations,
5eliminating duplication and ensuring uniform user experience.
6"""
8from __future__ import annotations
10from datetime import datetime
11from typing import Any
14class ToolMessages:
15 """Centralized tool message formatting.
17 This class provides static methods for formatting common tool messages
18 with consistent styling and structure.
19 """
21 @staticmethod
22 def not_available(feature: str, install_hint: str = "") -> str:
23 """Format feature unavailable message.
25 Args:
26 feature: Name of the unavailable feature
27 install_hint: Optional installation instructions
29 Returns:
30 Formatted error message
32 Example:
33 >>> ToolMessages.not_available("Database", "uv sync --extra embeddings")
34 '❌ Database not available. Install: uv sync --extra embeddings'
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
45 @staticmethod
46 def operation_failed(operation: str, error: Exception | str) -> str:
47 """Format operation failure message.
49 Args:
50 operation: Name of the failed operation
51 error: Exception or error message
53 Returns:
54 Formatted error message
56 Example:
57 >>> ToolMessages.operation_failed("Search", ValueError("Bad input"))
58 '❌ Search failed: Bad input'
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}"
67 @staticmethod
68 def success(message: str, details: dict[str, Any] | None = None) -> str:
69 """Format success message with optional details.
71 Args:
72 message: Main success message
73 details: Optional dictionary of details to display
75 Returns:
76 Formatted success message with details
78 Example:
79 >>> ToolMessages.success("Stored", {"items": 5, "time": "1.2s"})
80 '✅ Stored
81 • items: 5
82 • time: 1.2s'
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)
91 @staticmethod
92 def validation_error(field: str, message: str) -> str:
93 """Format input validation error.
95 Args:
96 field: Name of the invalid field
97 message: Validation error description
99 Returns:
100 Formatted validation error
102 Example:
103 >>> ToolMessages.validation_error("email", "Invalid format")
104 '❌ Validation error: email - Invalid format'
106 """
107 return f"❌ Validation error: {field} - {message}"
109 @staticmethod
110 def empty_results(operation: str, suggestion: str = "") -> str:
111 """Format message for empty results.
113 Args:
114 operation: Operation that returned no results
115 suggestion: Optional suggestion for the user
117 Returns:
118 Formatted empty results message
120 Example:
121 >>> ToolMessages.empty_results("Search", "Try broader terms")
122 'i️ No results found for Search. Try broader terms'
124 """
125 msg = f"ℹ️ No results found for {operation}"
126 if suggestion:
127 msg += f". {suggestion}"
128 return msg
130 @staticmethod
131 def format_list_item(emoji: str, label: str, value: Any) -> str:
132 """Format a single list item with emoji and label.
134 Args:
135 emoji: Emoji to use for the item
136 label: Label for the item
137 value: Value to display
139 Returns:
140 Formatted list item
142 Example:
143 >>> ToolMessages.format_list_item("📝", "Content", "Hello world")
144 '📝 Content: Hello world'
146 """
147 return f"{emoji} {label}: {value}"
149 @staticmethod
150 def format_timestamp(dt: datetime | None = None) -> str:
151 """Format a timestamp consistently.
153 Args:
154 dt: Datetime to format (defaults to now)
156 Returns:
157 Formatted timestamp string
159 Example:
160 >>> ToolMessages.format_timestamp()
161 '2025-01-12 14:30:45'
163 """
164 if dt is None:
165 dt = datetime.now()
166 return dt.strftime("%Y-%m-%d %H:%M:%S")
168 @staticmethod
169 def format_count(count: int, singular: str, plural: str | None = None) -> str:
170 """Format a count with appropriate singular/plural form.
172 Args:
173 count: Number to format
174 singular: Singular form of the noun
175 plural: Plural form (defaults to singular + 's')
177 Returns:
178 Formatted count string
180 Example:
181 >>> ToolMessages.format_count(1, "result")
182 '1 result'
183 >>> ToolMessages.format_count(5, "match", "matches")
184 '5 matches'
186 """
187 if plural is None:
188 plural = f"{singular}s"
189 word = singular if count == 1 else plural
190 return f"{count} {word}"
192 @staticmethod
193 def format_progress(current: int, total: int, operation: str = "") -> str:
194 """Format a progress indicator.
196 Args:
197 current: Current progress
198 total: Total items
199 operation: Optional operation description
201 Returns:
202 Formatted progress string
204 Example:
205 >>> ToolMessages.format_progress(5, 10, "Processing")
206 'Processing: 5/10 (50%)'
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
215 @staticmethod
216 def format_duration(seconds: float) -> str:
217 """Format a duration in seconds to human-readable form.
219 Args:
220 seconds: Duration in seconds
222 Returns:
223 Formatted duration string
225 Example:
226 >>> ToolMessages.format_duration(65.5)
227 '1m 5.5s'
228 >>> ToolMessages.format_duration(3.2)
229 '3.2s'
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"
238 @staticmethod
239 def format_bytes(bytes_count: int) -> str:
240 """Format byte count to human-readable form.
242 Args:
243 bytes_count: Number of bytes
245 Returns:
246 Formatted byte string with appropriate unit
248 Example:
249 >>> ToolMessages.format_bytes(1500)
250 '1.5 KB'
251 >>> ToolMessages.format_bytes(1_500_000)
252 '1.4 MB'
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"
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.
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
277 Returns:
278 Formatted result summary
280 Example:
281 >>> results = ["a", "b", "c"]
282 >>> ToolMessages.format_result_summary(results, "Search")
283 '✅ Search complete: 3 results'
285 """
286 count = len(results)
288 if count == 0:
289 return ToolMessages.empty_results(operation)
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")
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}")
305 if count > max_display:
306 lines.append(f" ... and {count - max_display} more")
308 return "\n".join(lines)
310 @staticmethod
311 def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
312 """Truncate text to maximum length with suffix.
314 Args:
315 text: Text to truncate
316 max_length: Maximum length before truncation
317 suffix: Suffix to add when truncated
319 Returns:
320 Truncated text with suffix if needed
322 Example:
323 >>> ToolMessages.truncate_text("Hello world this is long", 15)
324 'Hello world ...'
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