diff --git a/src/pieces/app.py b/src/pieces/app.py index 28450633..1e9e1df1 100644 --- a/src/pieces/app.py +++ b/src/pieces/app.py @@ -5,6 +5,7 @@ from pieces.config.constants import PIECES_DATA_DIR from pieces.config.migration import run_migration +from pieces.errors import format_error from pieces.headless.exceptions import HeadlessError from pieces.headless.models.base import ErrorCode from pieces.headless.output import HeadlessOutput @@ -171,7 +172,11 @@ def main(): else: Settings.logger.critical(e, ignore_sentry=True) try: - Settings.show_error("UNKNOWN EXCEPTION", e) + # Display user-friendly error message + friendly_message = format_error(e, include_technical=False) + Settings.logger.console_error.print(f"[red]{friendly_message}[/red]") + if not Settings.run_in_loop: + sys.exit(2) except SystemExit: sentry_sdk.flush(2) raise diff --git a/src/pieces/errors/__init__.py b/src/pieces/errors/__init__.py new file mode 100644 index 00000000..4fe01925 --- /dev/null +++ b/src/pieces/errors/__init__.py @@ -0,0 +1,20 @@ +""" +User-friendly error handling for Pieces CLI. + +This module provides utilities for transforming technical error messages +into helpful, actionable messages for users. +""" + +from .user_friendly import ( + UserFriendlyError, + ErrorCategory, + format_error, + get_user_friendly_message, +) + +__all__ = [ + "UserFriendlyError", + "ErrorCategory", + "format_error", + "get_user_friendly_message", +] diff --git a/src/pieces/errors/user_friendly.py b/src/pieces/errors/user_friendly.py new file mode 100644 index 00000000..0a67bb53 --- /dev/null +++ b/src/pieces/errors/user_friendly.py @@ -0,0 +1,518 @@ +""" +User-friendly error messages for Pieces CLI. + +This module transforms technical error messages into helpful, actionable +messages that guide users toward solutions. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, List, Dict +import re +import socket + +from pieces.urls import URLs + + +class ErrorCategory(Enum): + """Categories of errors for classification.""" + + CONNECTION = "connection" + AUTHENTICATION = "authentication" + PERMISSION = "permission" + NOT_FOUND = "not_found" + TIMEOUT = "timeout" + VERSION = "version" + CONFIGURATION = "configuration" + RATE_LIMIT = "rate_limit" + SERVER = "server" + UNKNOWN = "unknown" + + +@dataclass +class UserFriendlyError: + """ + Represents a user-friendly error message with context and solutions. + + Attributes: + title: A brief description of what went wrong + reasons: Possible reasons for the error + solutions: Steps the user can try to resolve the issue + help_link: URL to documentation or troubleshooting guide + category: The category of error for classification + original_error: The original exception that was caught + technical_details: Optional technical details for debugging + """ + + title: str + reasons: List[str] = field(default_factory=list) + solutions: List[str] = field(default_factory=list) + help_link: Optional[str] = None + category: ErrorCategory = ErrorCategory.UNKNOWN + original_error: Optional[Exception] = None + technical_details: Optional[str] = None + + def format_error(self, include_technical: bool = False) -> str: + """ + Format the error into a user-friendly string. + + Args: + include_technical: Whether to include technical details + + Returns: + A formatted error message string + """ + lines = [] + + # Title with error indicator + lines.append(f"❌ {self.title}") + lines.append("") + + # Possible reasons + if self.reasons: + lines.append("Possible reasons:") + for reason in self.reasons: + lines.append(f" • {reason}") + lines.append("") + + # Solutions + if self.solutions: + lines.append("Try these solutions:") + for i, solution in enumerate(self.solutions, 1): + lines.append(f" {i}. {solution}") + lines.append("") + + # Help link + if self.help_link: + lines.append(f"📖 More help: {self.help_link}") + + # Technical details (optional, for debugging) + if include_technical and self.technical_details: + lines.append("") + lines.append(f"Technical details: {self.technical_details}") + + return "\n".join(lines) + + def __str__(self) -> str: + return self.format_error() + + +# Error patterns and their corresponding user-friendly messages +ERROR_PATTERNS: Dict[str, Dict] = { + # Connection errors + r"Connection refused|ConnectionRefusedError|\[Errno 61\]|\[Errno 111\]|\[WinError 10061\]": { + "title": "Cannot connect to Pieces OS", + "reasons": [ + "Pieces OS may not be running", + "Pieces OS may be starting up", + "The service port may be blocked", + ], + "solutions": [ + "Ensure Pieces OS is running: `pieces open`", + "Check if Pieces OS is starting: `pieces status`", + "Restart Pieces OS: `pieces restart`", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.CONNECTION, + }, + + r"WebSocket.*failed|WebSocket.*error|WebSocketException|WebSocketConnectionClosedException": { + "title": "WebSocket connection failed", + "reasons": [ + "Pieces OS may not be running", + "Network connection issues", + "Firewall blocking the connection", + ], + "solutions": [ + "Ensure Pieces OS is running: `pieces open`", + "Check your network connection", + "Try restarting Pieces OS: `pieces restart`", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.CONNECTION, + }, + + r"timeout|TimeoutError|timed out|ETIMEDOUT": { + "title": "Operation timed out", + "reasons": [ + "Pieces OS is taking too long to respond", + "Network connection is slow or unstable", + "The operation is processing a large amount of data", + ], + "solutions": [ + "Wait a moment and try again", + "Check your network connection", + "Restart Pieces OS: `pieces restart`", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.TIMEOUT, + }, + + r"MaxRetryError|Max retries exceeded|ConnectionError": { + "title": "Cannot reach Pieces OS", + "reasons": [ + "Pieces OS may not be running", + "Too many failed connection attempts", + "Network issues preventing connection", + ], + "solutions": [ + "Start Pieces OS: `pieces open`", + "Check if Pieces OS is running: `pieces status`", + "Check your network connection", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.CONNECTION, + }, + + # Authentication errors + r"401|Unauthorized|authentication failed|invalid.*token|UnauthorizedException": { + "title": "Authentication failed", + "reasons": [ + "You may not be signed in", + "Your session may have expired", + "Invalid credentials", + ], + "solutions": [ + "Sign in to Pieces: `pieces login`", + "If already signed in, try signing out and back in: `pieces logout` then `pieces login`", + ], + "help_link": URLs.CLI_LOGIN_DOCS.value, + "category": ErrorCategory.AUTHENTICATION, + }, + + r"403|Forbidden|ForbiddenException|access denied|permission denied": { + "title": "Access denied", + "reasons": [ + "You don't have permission for this operation", + "The resource may be restricted", + "Your account may have limitations", + ], + "solutions": [ + "Verify you're signed in with the correct account: `pieces login`", + "Check if you have the necessary permissions", + ], + "help_link": URLs.CLI_LOGIN_DOCS.value, + "category": ErrorCategory.PERMISSION, + }, + + # Not found errors + r"404|Not Found|NotFoundException|does not exist|not found": { + "title": "Resource not found", + "reasons": [ + "The requested item may have been deleted", + "The item ID or name may be incorrect", + "The resource may not exist yet", + ], + "solutions": [ + "Verify the item exists: `pieces list`", + "Check the spelling of the item name or ID", + "Create the resource if it doesn't exist", + ], + "help_link": URLs.CLI_LIST_DOCS.value, + "category": ErrorCategory.NOT_FOUND, + }, + + # Version compatibility errors + r"version.*incompatible|update.*required|too old|CLI.*update|PiecesOS.*update": { + "title": "Version compatibility issue", + "reasons": [ + "Your Pieces CLI version may be outdated", + "Your Pieces OS version may need updating", + "Version mismatch between CLI and Pieces OS", + ], + "solutions": [ + "Update Pieces CLI: `pip install --upgrade pieces-cli`", + "Update Pieces OS from https://pieces.app", + "Check versions: `pieces version`", + ], + "help_link": URLs.DOCS_INSTALLATION.value, + "category": ErrorCategory.VERSION, + }, + + # Rate limit errors + r"429|rate limit|too many requests|quota exceeded": { + "title": "Rate limit exceeded", + "reasons": [ + "Too many requests in a short time", + "API quota has been reached", + ], + "solutions": [ + "Wait a few moments before trying again", + "Reduce the frequency of requests", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.RATE_LIMIT, + }, + + # Server errors + r"500|502|503|504|Internal Server Error|ServiceException|server error": { + "title": "Server error", + "reasons": [ + "Pieces OS encountered an internal error", + "The service may be temporarily unavailable", + "Server overload", + ], + "solutions": [ + "Wait a moment and try again", + "Restart Pieces OS: `pieces restart`", + "Check Pieces status at https://status.pieces.app", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.SERVER, + }, + + # Permission/File system errors + r"PermissionError|Permission denied|EACCES|access is denied": { + "title": "Permission denied", + "reasons": [ + "Insufficient file system permissions", + "The file or directory may be protected", + "Another process may be using the file", + ], + "solutions": [ + "Check file/folder permissions", + "Run with appropriate permissions", + "Close other applications that may be using the file", + ], + "help_link": URLs.CLI_HELP_DOCS.value, + "category": ErrorCategory.PERMISSION, + }, + + # Configuration errors + r"config.*error|invalid.*config|configuration.*failed|FileNotFoundError.*config": { + "title": "Configuration error", + "reasons": [ + "Configuration file may be missing or corrupted", + "Invalid configuration values", + "Configuration file permissions issue", + ], + "solutions": [ + "Reset configuration: `pieces config --reset`", + "Check configuration: `pieces config --show`", + "Verify configuration file permissions", + ], + "help_link": URLs.CLI_CONFIG_DOCS.value, + "category": ErrorCategory.CONFIGURATION, + }, + + # MCP specific errors + r"MCP.*failed|MCP.*error|mcp.*not.*running": { + "title": "MCP server error", + "reasons": [ + "MCP server may not be running", + "MCP configuration may be incorrect", + "Connection to MCP server failed", + ], + "solutions": [ + "Start MCP server: `pieces mcp start`", + "Check MCP status: `pieces mcp status`", + "Repair MCP configuration: `pieces mcp repair`", + ], + "help_link": URLs.CLI_MCP_DOCS.value, + "category": ErrorCategory.CONNECTION, + }, + + # Git/Commit errors + r"git.*error|not a git repository|commit.*failed": { + "title": "Git operation failed", + "reasons": [ + "Not in a git repository", + "Git is not installed or not in PATH", + "No changes to commit", + ], + "solutions": [ + "Ensure you're in a git repository", + "Initialize a git repository: `git init`", + "Check if there are changes to commit: `git status`", + ], + "help_link": URLs.CLI_COMMIT_DOCS.value, + "category": ErrorCategory.CONFIGURATION, + }, +} + + +def _match_error_pattern(error_message: str) -> Optional[Dict]: + """ + Match an error message against known patterns. + + Args: + error_message: The error message to match + + Returns: + The matching error configuration or None + """ + for pattern, config in ERROR_PATTERNS.items(): + if re.search(pattern, error_message, re.IGNORECASE): + return config + return None + + +def get_user_friendly_message( + exception: Exception, + default_help_link: str = None, +) -> UserFriendlyError: + """ + Transform an exception into a user-friendly error message. + + Args: + exception: The exception to transform + default_help_link: Default help URL if no specific one is found + + Returns: + A UserFriendlyError with helpful information + """ + error_message = str(exception) + exception_type = type(exception).__name__ + + # Combine exception type and message for matching + full_error = f"{exception_type}: {error_message}" + + # Check specific exception types FIRST (before pattern matching) + # This ensures we get the most specific handler for known exception types + + # Use default help link if not provided + if default_help_link is None: + default_help_link = URLs.CLI_HELP_DOCS.value + + if isinstance(exception, ConnectionRefusedError): + return UserFriendlyError( + title="Cannot connect to Pieces OS", + reasons=[ + "Pieces OS may not be running", + "The service port may be blocked", + ], + solutions=[ + "Ensure Pieces OS is running: `pieces open`", + "Restart Pieces OS: `pieces restart`", + ], + help_link=URLs.CLI_HELP_DOCS.value, + category=ErrorCategory.CONNECTION, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, OSError) and getattr(exception, "errno", None) in (61, 111, 10061): + return UserFriendlyError( + title="Cannot connect to Pieces OS", + reasons=[ + "Pieces OS may not be running", + "The service port may be blocked", + ], + solutions=[ + "Ensure Pieces OS is running: `pieces open`", + "Restart Pieces OS: `pieces restart`", + ], + help_link=URLs.CLI_HELP_DOCS.value, + category=ErrorCategory.CONNECTION, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, TimeoutError) or isinstance(exception, socket.timeout): + return UserFriendlyError( + title="Operation timed out", + reasons=[ + "The operation took too long to complete", + "Network connection may be slow", + ], + solutions=[ + "Try again in a moment", + "Check your network connection", + "Restart Pieces OS: `pieces restart`", + ], + help_link=URLs.CLI_HELP_DOCS.value, + category=ErrorCategory.TIMEOUT, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, PermissionError): + return UserFriendlyError( + title="Permission denied", + reasons=[ + "Insufficient file system permissions", + "The file or directory may be protected", + ], + solutions=[ + "Check file/folder permissions", + "Run with appropriate permissions", + ], + help_link=URLs.CLI_HELP_DOCS.value, + category=ErrorCategory.PERMISSION, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, FileNotFoundError): + return UserFriendlyError( + title="File not found", + reasons=[ + "The specified file does not exist", + "The file path may be incorrect", + ], + solutions=[ + "Verify the file path is correct", + "Check if the file exists", + ], + help_link=URLs.CLI_HELP_DOCS.value, + category=ErrorCategory.NOT_FOUND, + original_error=exception, + technical_details=full_error, + ) + + # Try to match against known patterns for other errors + config = _match_error_pattern(full_error) + + if config: + return UserFriendlyError( + title=config["title"], + reasons=config.get("reasons", []), + solutions=config.get("solutions", []), + help_link=config.get("help_link", default_help_link), + category=config.get("category", ErrorCategory.UNKNOWN), + original_error=exception, + technical_details=full_error, + ) + + # Default fallback for unknown errors + return UserFriendlyError( + title="An unexpected error occurred", + reasons=[ + "An internal error occurred", + "This may be a temporary issue", + ], + solutions=[ + "Try again in a moment", + "Restart Pieces OS: `pieces restart`", + "If the problem persists, report it: `pieces feedback`", + ], + help_link=default_help_link, + category=ErrorCategory.UNKNOWN, + original_error=exception, + technical_details=full_error, + ) + + +def format_error( + exception: Exception, + include_technical: bool = False, + default_help_link: str = None, +) -> str: + """ + Format an exception into a user-friendly error string. + + This is a convenience function that combines get_user_friendly_message + and UserFriendlyError.format_error. + + Args: + exception: The exception to format + include_technical: Whether to include technical details + default_help_link: Default help URL if no specific one is found + + Returns: + A formatted error message string + """ + if default_help_link is None: + default_help_link = URLs.CLI_HELP_DOCS.value + friendly_error = get_user_friendly_message(exception, default_help_link) + return friendly_error.format_error(include_technical) diff --git a/src/pieces/settings.py b/src/pieces/settings.py index 67a334aa..5f0e3588 100644 --- a/src/pieces/settings.py +++ b/src/pieces/settings.py @@ -129,10 +129,29 @@ def check_login(cls): @classmethod def show_error(cls, error, error_message=None): - cls.logger.console_error.print(f"[red]{error}") - cls.logger.console_error.print( - f"[red]{error_message}" - ) if error_message else None + """Display an error message to the user. + + Args: + error: The error title or Exception object + error_message: Optional additional error message + """ + from pieces.errors import format_error + + # If error is an Exception, format it as user-friendly + if isinstance(error, Exception): + friendly_message = format_error(error, include_technical=False) + cls.logger.console_error.print(f"[red]{friendly_message}[/red]") + else: + # Legacy behavior for string errors + cls.logger.console_error.print(f"[red]{error}[/red]") + if error_message: + # Check if error_message is an Exception for user-friendly formatting + if isinstance(error_message, Exception): + friendly_message = format_error(error_message, include_technical=False) + cls.logger.console_error.print(f"[red]{friendly_message}[/red]") + else: + cls.logger.console_error.print(f"[red]{error_message}[/red]") + if not cls.run_in_loop: sys.exit(2) diff --git a/tests/test_user_friendly_errors.py b/tests/test_user_friendly_errors.py new file mode 100644 index 00000000..b190d033 --- /dev/null +++ b/tests/test_user_friendly_errors.py @@ -0,0 +1,217 @@ +""" +Tests for user-friendly error handling. +""" + +from pieces.errors import ( + UserFriendlyError, + ErrorCategory, + format_error, + get_user_friendly_message, +) + + +class TestUserFriendlyError: + """Tests for the UserFriendlyError class.""" + + def test_basic_error_creation(self): + """Test creating a basic user-friendly error.""" + error = UserFriendlyError( + title="Test error", + reasons=["Reason 1", "Reason 2"], + solutions=["Solution 1", "Solution 2"], + help_link="https://example.com", + category=ErrorCategory.CONNECTION, + ) + + assert error.title == "Test error" + assert len(error.reasons) == 2 + assert len(error.solutions) == 2 + assert error.help_link == "https://example.com" + assert error.category == ErrorCategory.CONNECTION + + def test_format_error_output(self): + """Test that format_error produces expected output structure.""" + error = UserFriendlyError( + title="Cannot connect to Pieces OS", + reasons=["Pieces OS may not be running"], + solutions=["Run: pieces open"], + help_link="https://docs.pieces.app", + ) + + formatted = error.format_error() + + assert "❌ Cannot connect to Pieces OS" in formatted + assert "Possible reasons:" in formatted + assert "• Pieces OS may not be running" in formatted + assert "Try these solutions:" in formatted + assert "1. Run: pieces open" in formatted + assert "📖 More help: https://docs.pieces.app" in formatted + + def test_format_error_with_technical_details(self): + """Test that technical details are included when requested.""" + error = UserFriendlyError( + title="Test error", + technical_details="ConnectionRefusedError: [Errno 61]", + ) + + formatted_without = error.format_error(include_technical=False) + formatted_with = error.format_error(include_technical=True) + + assert "ConnectionRefusedError" not in formatted_without + assert "ConnectionRefusedError" in formatted_with + + def test_str_method(self): + """Test that __str__ returns formatted output.""" + error = UserFriendlyError(title="Test error") + assert str(error) == error.format_error() + + +class TestGetUserFriendlyMessage: + """Tests for the get_user_friendly_message function.""" + + def test_connection_refused_error(self): + """Test handling of ConnectionRefusedError.""" + exception = ConnectionRefusedError("[Errno 61] Connection refused") + friendly = get_user_friendly_message(exception) + + assert "connect" in friendly.title.lower() + assert friendly.category == ErrorCategory.CONNECTION + assert len(friendly.solutions) > 0 + + def test_timeout_error(self): + """Test handling of TimeoutError.""" + exception = TimeoutError("Operation timed out") + friendly = get_user_friendly_message(exception) + + assert "timed out" in friendly.title.lower() + assert friendly.category == ErrorCategory.TIMEOUT + + def test_permission_error(self): + """Test handling of PermissionError.""" + exception = PermissionError("Permission denied") + friendly = get_user_friendly_message(exception) + + assert "denied" in friendly.title.lower() or "permission" in friendly.title.lower() + assert friendly.category == ErrorCategory.PERMISSION + + def test_file_not_found_error(self): + """Test handling of FileNotFoundError.""" + exception = FileNotFoundError("File not found: test.txt") + friendly = get_user_friendly_message(exception) + + assert "not found" in friendly.title.lower() + assert friendly.category == ErrorCategory.NOT_FOUND + + def test_unknown_error_fallback(self): + """Test that unknown errors get a reasonable fallback.""" + exception = Exception("Some unknown error") + friendly = get_user_friendly_message(exception) + + assert friendly.title is not None + assert friendly.category == ErrorCategory.UNKNOWN + assert friendly.original_error == exception + + def test_websocket_error_pattern(self): + """Test WebSocket error pattern matching.""" + exception = Exception("WebSocket connection failed: Connection refused") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.CONNECTION + assert "websocket" in friendly.title.lower() or "connect" in friendly.title.lower() + + def test_authentication_error_pattern(self): + """Test authentication error pattern matching.""" + exception = Exception("401 Unauthorized: Invalid token") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.AUTHENTICATION + + def test_rate_limit_error_pattern(self): + """Test rate limit error pattern matching.""" + exception = Exception("429 Too Many Requests: Rate limit exceeded") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.RATE_LIMIT + + def test_server_error_pattern(self): + """Test server error pattern matching.""" + exception = Exception("500 Internal Server Error") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.SERVER + + +class TestFormatError: + """Tests for the format_error convenience function.""" + + def test_format_error_convenience(self): + """Test that format_error returns a string.""" + exception = ConnectionRefusedError("Connection refused") + result = format_error(exception) + + assert isinstance(result, str) + assert "❌" in result + + def test_format_error_with_technical(self): + """Test format_error with technical details.""" + exception = ConnectionRefusedError("Connection refused") + result = format_error(exception, include_technical=True) + + assert "Technical details:" in result + + +class TestErrorCategories: + """Tests for error category classification.""" + + def test_all_categories_exist(self): + """Test that all expected categories exist.""" + categories = [ + ErrorCategory.CONNECTION, + ErrorCategory.AUTHENTICATION, + ErrorCategory.PERMISSION, + ErrorCategory.NOT_FOUND, + ErrorCategory.TIMEOUT, + ErrorCategory.VERSION, + ErrorCategory.CONFIGURATION, + ErrorCategory.RATE_LIMIT, + ErrorCategory.SERVER, + ErrorCategory.UNKNOWN, + ] + + for category in categories: + assert category.value is not None + + +class TestRealWorldScenarios: + """Integration-style tests for real-world error scenarios.""" + + def test_pieces_os_not_running_scenario(self): + """Test the scenario when Pieces OS is not running.""" + # Simulate the error that occurs when Pieces OS isn't running + exception = ConnectionRefusedError("[Errno 61] Connection refused") + formatted = format_error(exception) + + # Should mention Pieces OS + assert "pieces" in formatted.lower() or "connect" in formatted.lower() + # Should suggest running pieces open + assert "pieces" in formatted.lower() + + def test_websocket_connection_failed_scenario(self): + """Test the WebSocket connection failed scenario from the issue.""" + # This is the exact error type mentioned in the GitHub issue + exception = Exception("WebSocket connection failed: [Errno 61] Connection refused") + formatted = format_error(exception) + + # Verify it produces the expected user-friendly output + assert "❌" in formatted + assert "Possible reasons:" in formatted + assert "Try these solutions:" in formatted + assert "📖 More help:" in formatted + + def test_max_retry_error_scenario(self): + """Test MaxRetryError scenario.""" + exception = Exception("MaxRetryError: Max retries exceeded with url") + formatted = format_error(exception) + + assert "❌" in formatted + assert len(formatted) > 50 # Should have substantial content