From e5463b12ec095175857370928d8f64a6a17538e6 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 30 Sep 2025 12:28:42 -0400 Subject: [PATCH 1/2] CLI-10: Implement __rich__() method for ParseError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Rich protocol implementation to ParseError class enabling automatic themed rendering when displayed through Rich consoles. This eliminates the need for manual ErrorFormatter usage at error catch points. ## Implementation - Add __rich__() method returning rich.console.Group with styled components - Map error_type to StatusToken (syntax→ERROR, unknown_command→WARNING, etc.) - Style suggestions with HierarchyToken by ranking (PRIMARY, SECONDARY, TERTIARY) - Auto-limit suggestions to 3 maximum for concise output - Add 25 comprehensive unit tests for Rich rendering ## Benefits - Centralized styling in ParseError class - Works automatically with console.print(error) - Fully backward compatible with existing code - MyPy strict mode compliant - No manual ErrorFormatter needed at catch points All 602 tests pass. Closes CLI-10. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli_patterns/ui/parser/types.py | 127 ++++++++++++ tests/unit/ui/parser/test_types.py | 302 ++++++++++++++++++++++++++++ 2 files changed, 429 insertions(+) diff --git a/src/cli_patterns/ui/parser/types.py b/src/cli_patterns/ui/parser/types.py index 7e9df44..1c6ac15 100644 --- a/src/cli_patterns/ui/parser/types.py +++ b/src/cli_patterns/ui/parser/types.py @@ -5,6 +5,11 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from rich.console import Group, RenderableType +from rich.text import Text + +from cli_patterns.ui.design.tokens import HierarchyToken, StatusToken + if TYPE_CHECKING: pass @@ -115,6 +120,128 @@ def __str__(self) -> str: """String representation of the error.""" return f"{self.error_type}: {self.message}" + def __rich__(self) -> RenderableType: + """Rich rendering protocol implementation for automatic themed display. + + Returns: + RenderableType (Group) containing styled error message and suggestions + """ + # Map error_type to StatusToken + status_token = self._get_status_token() + + # Create styled error message + error_text = Text() + error_text.append( + f"{self.error_type}: ", style=self._get_status_style(status_token) + ) + error_text.append(self.message) + + # Create suggestions list with hierarchy styling (limit to 3) + renderables: list[RenderableType] = [error_text] + + if self.suggestions: + # Add "Did you mean:" prompt + prompt_text = Text( + "\n\nDid you mean:", style=self._get_status_style(StatusToken.INFO) + ) + renderables.append(prompt_text) + + # Add up to 3 suggestions with hierarchy styling + for idx, suggestion in enumerate(self.suggestions[:3]): + hierarchy = self._get_suggestion_hierarchy(idx) + suggestion_text = Text() + suggestion_text.append( + f"\n • {suggestion}", style=self._get_hierarchy_style(hierarchy) + ) + renderables.append(suggestion_text) + + return Group(*renderables) + + def _get_status_token(self) -> StatusToken: + """Map error_type to appropriate StatusToken. + + Returns: + StatusToken based on error_type + """ + error_type_lower = self.error_type.lower() + + if "syntax" in error_type_lower: + return StatusToken.ERROR + elif ( + "unknown_command" in error_type_lower + or "command_not_found" in error_type_lower + ): + return StatusToken.WARNING + elif ( + "invalid_args" in error_type_lower or "invalid_argument" in error_type_lower + ): + return StatusToken.ERROR + elif "deprecated" in error_type_lower: + return StatusToken.WARNING + else: + # Default to ERROR for unknown error types + return StatusToken.ERROR + + def _get_suggestion_hierarchy(self, index: int) -> HierarchyToken: + """Get hierarchy token for suggestion based on ranking. + + Args: + index: Position in suggestions list (0-based) + + Returns: + HierarchyToken indicating visual importance + """ + if index == 0: + return HierarchyToken.PRIMARY # Best match + elif index == 1: + return HierarchyToken.SECONDARY # Good match + else: + return HierarchyToken.TERTIARY # Possible match + + def _get_status_style(self, status: StatusToken) -> str: + """Get Rich style string for StatusToken. + + Args: + status: StatusToken to convert to style + + Returns: + Rich style string + """ + if status == StatusToken.ERROR: + return "bold red" + elif status == StatusToken.WARNING: + return "bold yellow" + elif status == StatusToken.INFO: + return "blue" + elif status == StatusToken.SUCCESS: + return "bold green" + elif status == StatusToken.RUNNING: + return "cyan" + elif status == StatusToken.MUTED: + return "dim" + else: + return "default" + + def _get_hierarchy_style(self, hierarchy: HierarchyToken) -> str: + """Get Rich style string for HierarchyToken. + + Args: + hierarchy: HierarchyToken to convert to style + + Returns: + Rich style string + """ + if hierarchy == HierarchyToken.PRIMARY: + return "bold" + elif hierarchy == HierarchyToken.SECONDARY: + return "default" + elif hierarchy == HierarchyToken.TERTIARY: + return "dim" + elif hierarchy == HierarchyToken.QUATERNARY: + return "dim italic" + else: + return "default" + @dataclass class Context: diff --git a/tests/unit/ui/parser/test_types.py b/tests/unit/ui/parser/test_types.py index 7008824..7f16567 100644 --- a/tests/unit/ui/parser/test_types.py +++ b/tests/unit/ui/parser/test_types.py @@ -5,12 +5,14 @@ from typing import Any import pytest +from rich.console import Console, Group from cli_patterns.ui.design.tokens import ( CategoryToken, DisplayMetadata, EmphasisToken, HierarchyToken, + StatusToken, ) from cli_patterns.ui.parser.types import ( CommandArgs, @@ -545,6 +547,306 @@ def test_error_serialization_with_metadata(self) -> None: assert error.display_metadata.hierarchy == HierarchyToken.SECONDARY +class TestParseErrorRichRendering: + """Test ParseError's Rich protocol implementation (__rich__ method).""" + + def test_rich_method_returns_group(self) -> None: + """Test that __rich__() returns a Rich Group.""" + error = ParseError( + error_type="TEST_ERROR", + message="Test error message", + suggestions=["Suggestion 1"], + ) + + result = error.__rich__() + assert isinstance(result, Group) + + def test_rich_rendering_without_suggestions(self) -> None: + """Test Rich rendering of error without suggestions.""" + error = ParseError( + error_type="NO_SUGGESTIONS_ERROR", + message="Error without any suggestions", + suggestions=[], + ) + + result = error.__rich__() + assert isinstance(result, Group) + + # Convert to text to inspect content + console = Console() + with console.capture() as capture: + console.print(result) + output = capture.get() + + assert "NO_SUGGESTIONS_ERROR" in output + assert "Error without any suggestions" in output + assert "Did you mean:" not in output + + def test_rich_rendering_with_suggestions(self) -> None: + """Test Rich rendering of error with suggestions.""" + error = ParseError( + error_type="WITH_SUGGESTIONS", + message="Error with suggestions", + suggestions=["First suggestion", "Second suggestion"], + ) + + result = error.__rich__() + assert isinstance(result, Group) + + # Convert to text to inspect content + console = Console() + with console.capture() as capture: + console.print(result) + output = capture.get() + + assert "WITH_SUGGESTIONS" in output + assert "Error with suggestions" in output + assert "Did you mean:" in output + assert "First suggestion" in output + assert "Second suggestion" in output + + def test_rich_rendering_limits_suggestions_to_three(self) -> None: + """Test that Rich rendering limits suggestions to max 3.""" + error = ParseError( + error_type="MANY_SUGGESTIONS", + message="Error with many suggestions", + suggestions=[ + "Suggestion 1", + "Suggestion 2", + "Suggestion 3", + "Suggestion 4", + "Suggestion 5", + ], + ) + + result = error.__rich__() + console = Console() + with console.capture() as capture: + console.print(result) + output = capture.get() + + # Should include first 3 suggestions + assert "Suggestion 1" in output + assert "Suggestion 2" in output + assert "Suggestion 3" in output + + # Should NOT include 4th and 5th suggestions + assert "Suggestion 4" not in output + assert "Suggestion 5" not in output + + def test_error_type_to_status_token_mapping_syntax(self) -> None: + """Test that syntax errors map to ERROR status.""" + error = ParseError( + error_type="SYNTAX_ERROR", + message="Syntax error in command", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.ERROR + + def test_error_type_to_status_token_mapping_unknown_command(self) -> None: + """Test that unknown command errors map to WARNING status.""" + error = ParseError( + error_type="UNKNOWN_COMMAND", + message="Command not found", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.WARNING + + def test_error_type_to_status_token_mapping_invalid_args(self) -> None: + """Test that invalid args errors map to ERROR status.""" + error = ParseError( + error_type="INVALID_ARGS", + message="Invalid arguments provided", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.ERROR + + def test_error_type_to_status_token_mapping_deprecated(self) -> None: + """Test that deprecated errors map to WARNING status.""" + error = ParseError( + error_type="DEPRECATED_COMMAND", + message="This command is deprecated", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.WARNING + + def test_error_type_to_status_token_mapping_default(self) -> None: + """Test that unknown error types default to ERROR status.""" + error = ParseError( + error_type="RANDOM_ERROR_TYPE", + message="Some unknown error", + suggestions=[], + ) + + status = error._get_status_token() + assert status == StatusToken.ERROR + + def test_suggestion_hierarchy_first_is_primary(self) -> None: + """Test that first suggestion gets PRIMARY hierarchy.""" + error = ParseError( + error_type="TEST", + message="Test", + suggestions=["First"], + ) + + hierarchy = error._get_suggestion_hierarchy(0) + assert hierarchy == HierarchyToken.PRIMARY + + def test_suggestion_hierarchy_second_is_secondary(self) -> None: + """Test that second suggestion gets SECONDARY hierarchy.""" + error = ParseError( + error_type="TEST", + message="Test", + suggestions=["First", "Second"], + ) + + hierarchy = error._get_suggestion_hierarchy(1) + assert hierarchy == HierarchyToken.SECONDARY + + def test_suggestion_hierarchy_third_is_tertiary(self) -> None: + """Test that third suggestion gets TERTIARY hierarchy.""" + error = ParseError( + error_type="TEST", + message="Test", + suggestions=["First", "Second", "Third"], + ) + + hierarchy = error._get_suggestion_hierarchy(2) + assert hierarchy == HierarchyToken.TERTIARY + + def test_status_style_error(self) -> None: + """Test ERROR status maps to correct style.""" + error = ParseError("TEST", "Test", []) + style = error._get_status_style(StatusToken.ERROR) + assert "red" in style + + def test_status_style_warning(self) -> None: + """Test WARNING status maps to correct style.""" + error = ParseError("TEST", "Test", []) + style = error._get_status_style(StatusToken.WARNING) + assert "yellow" in style + + def test_status_style_info(self) -> None: + """Test INFO status maps to correct style.""" + error = ParseError("TEST", "Test", []) + style = error._get_status_style(StatusToken.INFO) + assert "blue" in style + + def test_hierarchy_style_primary(self) -> None: + """Test PRIMARY hierarchy maps to bold style.""" + error = ParseError("TEST", "Test", []) + style = error._get_hierarchy_style(HierarchyToken.PRIMARY) + assert "bold" in style + + def test_hierarchy_style_tertiary(self) -> None: + """Test TERTIARY hierarchy maps to dim style.""" + error = ParseError("TEST", "Test", []) + style = error._get_hierarchy_style(HierarchyToken.TERTIARY) + assert "dim" in style + + def test_rich_rendering_with_console_print(self) -> None: + """Test that ParseError can be directly printed to Rich console.""" + error = ParseError( + error_type="CONSOLE_PRINT_TEST", + message="Testing console.print() integration", + suggestions=["Use console.print()", "Automatic styling works"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "CONSOLE_PRINT_TEST" in output + assert "Testing console.print() integration" in output + assert "Did you mean:" in output + assert "Use console.print()" in output + + def test_rich_rendering_preserves_multiline_messages(self) -> None: + """Test that multiline error messages are preserved in Rich rendering.""" + error = ParseError( + error_type="MULTILINE_ERROR", + message="First line of error\nSecond line of error\nThird line", + suggestions=["Fix the error"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "First line of error" in output + assert "Second line of error" in output + assert "Third line" in output + + def test_rich_rendering_handles_unicode(self) -> None: + """Test that Rich rendering handles Unicode characters correctly.""" + error = ParseError( + error_type="UNICODE_ERROR", + message="Error with Unicode: üñíçødé symbols", + suggestions=["Check encoding"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "üñíçødé" in output + + def test_rich_rendering_backward_compatible_with_str(self) -> None: + """Test that __str__() still works alongside __rich__().""" + error = ParseError( + error_type="BACKWARD_COMPAT", + message="Test backward compatibility", + suggestions=["Maintain compatibility"], + ) + + # __str__() should still work + str_output = str(error) + assert "BACKWARD_COMPAT" in str_output + assert "Test backward compatibility" in str_output + + # __rich__() should also work + result = error.__rich__() + assert isinstance(result, Group) + + @pytest.mark.parametrize( + "error_type,expected_in_output", + [ + ("syntax_error", "syntax_error"), + ("UNKNOWN_COMMAND_ERROR", "UNKNOWN_COMMAND_ERROR"), + ("invalid_args_provided", "invalid_args_provided"), + ("deprecated_feature", "deprecated_feature"), + ], + ) + def test_rich_rendering_various_error_types( + self, error_type: str, expected_in_output: str + ) -> None: + """Test Rich rendering with various error type patterns.""" + error = ParseError( + error_type=error_type, + message=f"Message for {error_type}", + suggestions=["Fix it"], + ) + + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert expected_in_output in output + assert f"Message for {error_type}" in output + + class TestContext: """Test Context class for parser state management.""" From 64993cb460a7c160e4cdec13d17738fb5a28f526 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 30 Sep 2025 12:55:27 -0400 Subject: [PATCH 2/2] refactor(parser): integrate theme registry for styled error rendering in ParseError --- Makefile | 11 +++- src/cli_patterns/ui/parser/types.py | 64 +++++---------------- tests/unit/ui/parser/test_types.py | 86 +++++++++++++++++++---------- 3 files changed, 82 insertions(+), 79 deletions(-) diff --git a/Makefile b/Makefile index 4f9e9aa..66bbfd3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # CLI Patterns Makefile # Development and testing automation -.PHONY: help install test test-unit test-integration test-coverage test-parser test-executor test-design test-fast test-components lint type-check format clean clean-docker all quality format-check ci-setup ci-native ci-docker verify-sync benchmark test-all ci-summary +.PHONY: help install test test-unit test-integration test-coverage test-parser test-executor test-design test-fast test-components lint lint-fix type-check format clean clean-docker all quality format-check ci-setup ci-native ci-docker verify-sync benchmark test-all ci-summary # Default target help: @@ -18,6 +18,7 @@ help: @echo "make test-fast - Run non-slow tests only" @echo "make test-components - Run all component tests (parser, executor, design)" @echo "make lint - Run ruff linter" + @echo "make lint-fix - Run ruff linter and auto-fix issues" @echo "make type-check - Run mypy type checking" @echo "make format - Format code with black" @echo "make clean - Remove build artifacts and cache" @@ -79,6 +80,14 @@ lint: ruff check src/ tests/; \ fi +# Lint code and auto-fix issues +lint-fix: + @if command -v uv > /dev/null 2>&1; then \ + uv run ruff check src/ tests/ --fix; \ + else \ + ruff check src/ tests/ --fix; \ + fi + # Type check with mypy type-check: @if command -v uv > /dev/null 2>&1; then \ diff --git a/src/cli_patterns/ui/parser/types.py b/src/cli_patterns/ui/parser/types.py index 1c6ac15..6d5acb0 100644 --- a/src/cli_patterns/ui/parser/types.py +++ b/src/cli_patterns/ui/parser/types.py @@ -8,6 +8,7 @@ from rich.console import Group, RenderableType from rich.text import Text +from cli_patterns.ui.design.registry import theme_registry from cli_patterns.ui.design.tokens import HierarchyToken, StatusToken if TYPE_CHECKING: @@ -92,6 +93,9 @@ def has_flag(self, flag: str) -> bool: class ParseError(Exception): """Exception raised during command parsing. + This class implements the Rich __rich__() protocol for automatic themed display + when printed to a Rich console. Use console.print(error) for best results. + Attributes: message: Human-readable error message error_type: Type of parsing error @@ -123,16 +127,19 @@ def __str__(self) -> str: def __rich__(self) -> RenderableType: """Rich rendering protocol implementation for automatic themed display. + Uses the global theme_registry to resolve design tokens to themed styles, + ensuring consistency with the application's design system. + Returns: RenderableType (Group) containing styled error message and suggestions """ # Map error_type to StatusToken status_token = self._get_status_token() - # Create styled error message + # Create styled error message using theme registry error_text = Text() error_text.append( - f"{self.error_type}: ", style=self._get_status_style(status_token) + f"{self.error_type}: ", style=theme_registry.resolve(status_token) ) error_text.append(self.message) @@ -140,9 +147,9 @@ def __rich__(self) -> RenderableType: renderables: list[RenderableType] = [error_text] if self.suggestions: - # Add "Did you mean:" prompt + # Add "Did you mean:" prompt using theme registry prompt_text = Text( - "\n\nDid you mean:", style=self._get_status_style(StatusToken.INFO) + "\n\nDid you mean:", style=theme_registry.resolve(StatusToken.INFO) ) renderables.append(prompt_text) @@ -151,7 +158,7 @@ def __rich__(self) -> RenderableType: hierarchy = self._get_suggestion_hierarchy(idx) suggestion_text = Text() suggestion_text.append( - f"\n • {suggestion}", style=self._get_hierarchy_style(hierarchy) + f"\n • {suggestion}", style=theme_registry.resolve(hierarchy) ) renderables.append(suggestion_text) @@ -185,6 +192,9 @@ def _get_status_token(self) -> StatusToken: def _get_suggestion_hierarchy(self, index: int) -> HierarchyToken: """Get hierarchy token for suggestion based on ranking. + The first suggestion is PRIMARY (best match), second is SECONDARY (good match), + and third is TERTIARY (possible match). + Args: index: Position in suggestions list (0-based) @@ -198,50 +208,6 @@ def _get_suggestion_hierarchy(self, index: int) -> HierarchyToken: else: return HierarchyToken.TERTIARY # Possible match - def _get_status_style(self, status: StatusToken) -> str: - """Get Rich style string for StatusToken. - - Args: - status: StatusToken to convert to style - - Returns: - Rich style string - """ - if status == StatusToken.ERROR: - return "bold red" - elif status == StatusToken.WARNING: - return "bold yellow" - elif status == StatusToken.INFO: - return "blue" - elif status == StatusToken.SUCCESS: - return "bold green" - elif status == StatusToken.RUNNING: - return "cyan" - elif status == StatusToken.MUTED: - return "dim" - else: - return "default" - - def _get_hierarchy_style(self, hierarchy: HierarchyToken) -> str: - """Get Rich style string for HierarchyToken. - - Args: - hierarchy: HierarchyToken to convert to style - - Returns: - Rich style string - """ - if hierarchy == HierarchyToken.PRIMARY: - return "bold" - elif hierarchy == HierarchyToken.SECONDARY: - return "default" - elif hierarchy == HierarchyToken.TERTIARY: - return "dim" - elif hierarchy == HierarchyToken.QUATERNARY: - return "dim italic" - else: - return "default" - @dataclass class Context: diff --git a/tests/unit/ui/parser/test_types.py b/tests/unit/ui/parser/test_types.py index 7f16567..01e629b 100644 --- a/tests/unit/ui/parser/test_types.py +++ b/tests/unit/ui/parser/test_types.py @@ -722,35 +722,63 @@ def test_suggestion_hierarchy_third_is_tertiary(self) -> None: hierarchy = error._get_suggestion_hierarchy(2) assert hierarchy == HierarchyToken.TERTIARY - def test_status_style_error(self) -> None: - """Test ERROR status maps to correct style.""" - error = ParseError("TEST", "Test", []) - style = error._get_status_style(StatusToken.ERROR) - assert "red" in style - - def test_status_style_warning(self) -> None: - """Test WARNING status maps to correct style.""" - error = ParseError("TEST", "Test", []) - style = error._get_status_style(StatusToken.WARNING) - assert "yellow" in style - - def test_status_style_info(self) -> None: - """Test INFO status maps to correct style.""" - error = ParseError("TEST", "Test", []) - style = error._get_status_style(StatusToken.INFO) - assert "blue" in style - - def test_hierarchy_style_primary(self) -> None: - """Test PRIMARY hierarchy maps to bold style.""" - error = ParseError("TEST", "Test", []) - style = error._get_hierarchy_style(HierarchyToken.PRIMARY) - assert "bold" in style - - def test_hierarchy_style_tertiary(self) -> None: - """Test TERTIARY hierarchy maps to dim style.""" - error = ParseError("TEST", "Test", []) - style = error._get_hierarchy_style(HierarchyToken.TERTIARY) - assert "dim" in style + def test_theme_registry_integration_status_tokens(self) -> None: + """Test that __rich__() uses theme_registry for StatusToken styling.""" + from cli_patterns.ui.design.registry import theme_registry + + error = ParseError( + error_type="SYNTAX_ERROR", + message="Test theme integration", + suggestions=[], + ) + + # Get the status token and verify it can be resolved + status = error._get_status_token() + style = theme_registry.resolve(status) + + # Verify theme registry returns a valid style string + assert isinstance(style, str) + assert len(style) > 0 + + def test_theme_registry_integration_hierarchy_tokens(self) -> None: + """Test that __rich__() uses theme_registry for HierarchyToken styling.""" + from cli_patterns.ui.design.registry import theme_registry + + error = ParseError( + error_type="TEST", + message="Test hierarchy styling", + suggestions=["First", "Second", "Third"], + ) + + # Get hierarchy tokens and verify they can be resolved + for idx in range(3): + hierarchy = error._get_suggestion_hierarchy(idx) + style = theme_registry.resolve(hierarchy) + + # Verify theme registry returns a valid style string + assert isinstance(style, str) + assert len(style) > 0 + + def test_theme_registry_integration_in_rich_output(self) -> None: + """Test that Rich rendering uses theme_registry resolved styles.""" + error = ParseError( + error_type="THEME_TEST", + message="Testing theme registry integration", + suggestions=["Suggestion 1"], + ) + + # Render with Rich + result = error.__rich__() + assert isinstance(result, Group) + + # Verify the error can be printed (theme resolution doesn't throw) + console = Console() + with console.capture() as capture: + console.print(error) + output = capture.get() + + assert "THEME_TEST" in output + assert "Testing theme registry integration" in output def test_rich_rendering_with_console_print(self) -> None: """Test that ParseError can be directly printed to Rich console."""