display_status.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. from __future__ import annotations
  2. import logging
  3. from typing import TYPE_CHECKING
  4. from rich import box
  5. from rich.console import Console, ConsoleOptions, RenderResult
  6. from rich.markdown import Heading, Markdown
  7. from rich.panel import Panel
  8. from .display_icons import IconManager
  9. from .display_settings import DisplaySettings
  10. if TYPE_CHECKING:
  11. from .display_base import BaseDisplay
  12. logger = logging.getLogger(__name__)
  13. console_err = Console(stderr=True) # Keep for error output
  14. class LeftAlignedHeading(Heading):
  15. """Custom Heading element with left alignment and no extra spacing."""
  16. def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
  17. text = self.text
  18. text.justify = "left" # Override center justification
  19. if self.tag == "h1":
  20. # Draw a border around h1s (left-aligned)
  21. yield Panel(
  22. text,
  23. box=box.HEAVY,
  24. style="markdown.h1.border",
  25. )
  26. else:
  27. # Styled text for h2 and beyond (no blank line before h2)
  28. yield text
  29. class LeftAlignedMarkdown(Markdown):
  30. """Custom Markdown renderer with left-aligned headings."""
  31. def __init__(self, markup: str, **kwargs):
  32. """Initialize with custom heading element."""
  33. super().__init__(markup, **kwargs)
  34. # Replace heading element to use left alignment
  35. self.elements["heading_open"] = LeftAlignedHeading
  36. class StatusDisplay:
  37. """Status messages and error display.
  38. Provides methods for displaying success, error, warning,
  39. and informational messages with consistent formatting.
  40. """
  41. def __init__(self, settings: DisplaySettings, quiet: bool, base: BaseDisplay):
  42. """Initialize StatusDisplay.
  43. Args:
  44. settings: Display settings for formatting
  45. quiet: If True, suppress non-error output
  46. base: BaseDisplay instance
  47. """
  48. self.settings = settings
  49. self.quiet = quiet
  50. self.base = base
  51. def _display_message(self, level: str, message: str, context: str | None = None) -> None:
  52. """Display a message with consistent formatting.
  53. Args:
  54. level: Message level (error, warning, success, info)
  55. message: The message to display
  56. context: Optional context information
  57. """
  58. # Errors and warnings always go to stderr, even in quiet mode
  59. # Success and info respect quiet mode and go to stdout
  60. use_stderr = level in ("error", "warning")
  61. should_print = use_stderr or not self.quiet
  62. if not should_print:
  63. return
  64. settings = self.settings
  65. colors = {
  66. "error": settings.COLOR_ERROR,
  67. "warning": settings.COLOR_WARNING,
  68. "success": settings.COLOR_SUCCESS,
  69. }
  70. color = colors.get(level)
  71. # Format message based on context
  72. if context:
  73. text = (
  74. f"{level.capitalize()} in {context}: {message}"
  75. if level in {"error", "warning"}
  76. else f"{context}: {message}"
  77. )
  78. else:
  79. text = f"{level.capitalize()}: {message}" if level in {"error", "warning"} else message
  80. # Only use icons and colors for actual status indicators (error, warning, success)
  81. # Plain info messages use default terminal color (no markup)
  82. if level in {"error", "warning", "success"}:
  83. icon = IconManager.get_status_icon(level)
  84. formatted_text = f"[{color}]{icon} {text}[/{color}]"
  85. else:
  86. formatted_text = text
  87. if use_stderr:
  88. console_err.print(formatted_text)
  89. else:
  90. self.base.text(formatted_text)
  91. # Log appropriately
  92. log_message = f"{context}: {message}" if context else message
  93. log_methods = {
  94. "error": logger.error,
  95. "warning": logger.warning,
  96. "success": logger.info,
  97. "info": logger.info,
  98. }
  99. log_methods.get(level, logger.info)(log_message)
  100. def error(self, message: str, context: str | None = None, details: str | None = None) -> None:
  101. """Display an error message.
  102. Args:
  103. message: Error message
  104. context: Optional context
  105. details: Optional additional details (shown in dim style on same line)
  106. """
  107. if details:
  108. # Combine message and details on same line with different formatting
  109. settings = self.settings
  110. color = settings.COLOR_ERROR
  111. icon = IconManager.get_status_icon("error")
  112. # Format: Icon Error: Message (details in dim)
  113. formatted = f"[{color}]{icon} Error: {message}[/{color}] [dim]({details})[/dim]"
  114. console_err.print(formatted)
  115. # Log at debug level to avoid duplicate console output (already printed to stderr)
  116. logger.debug(f"Error displayed: {message} ({details})")
  117. else:
  118. # No details, use standard display
  119. self._display_message("error", message, context)
  120. def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
  121. """Display a warning message.
  122. Args:
  123. message: Warning message
  124. context: Optional context
  125. details: Optional additional details (shown in dim style on same line)
  126. """
  127. if details:
  128. # Combine message and details on same line with different formatting
  129. settings = self.settings
  130. color = settings.COLOR_WARNING
  131. icon = IconManager.get_status_icon("warning")
  132. # Format: Icon Warning: Message (details in dim)
  133. formatted = f"[{color}]{icon} Warning: {message}[/{color}] [dim]({details})[/dim]"
  134. console_err.print(formatted)
  135. # Log at debug level to avoid duplicate console output (already printed to stderr)
  136. logger.debug(f"Warning displayed: {message} ({details})")
  137. else:
  138. # No details, use standard display
  139. self._display_message("warning", message, context)
  140. def success(self, message: str, context: str | None = None) -> None:
  141. """Display a success message.
  142. Args:
  143. message: Success message
  144. context: Optional context
  145. """
  146. self._display_message("success", message, context)
  147. def info(self, message: str, context: str | None = None) -> None:
  148. """Display an informational message.
  149. Args:
  150. message: Info message
  151. context: Optional context
  152. """
  153. self._display_message("info", message, context)
  154. def skipped(self, message: str, reason: str | None = None) -> None:
  155. """Display a skipped/disabled message.
  156. Args:
  157. message: The main message to display
  158. reason: Optional reason why it was skipped
  159. """
  160. if reason:
  161. self.base.text(f"\n{message} (skipped - {reason})", style="dim")
  162. else:
  163. self.base.text(f"\n{message} (skipped)", style="dim")
  164. def markdown(self, content: str) -> None:
  165. """Render markdown content with left-aligned headings.
  166. Args:
  167. content: Markdown-formatted text to render
  168. """
  169. if not self.quiet:
  170. console = Console()
  171. console.print(LeftAlignedMarkdown(content))