display.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. from __future__ import annotations
  2. import logging
  3. from pathlib import Path
  4. from typing import TYPE_CHECKING
  5. from rich.console import Console
  6. from rich.table import Table
  7. from rich.tree import Tree
  8. if TYPE_CHECKING:
  9. from .template import Template
  10. logger = logging.getLogger(__name__)
  11. console = Console()
  12. class IconManager:
  13. """Centralized icon management system for consistent CLI display.
  14. This class provides standardized icons for file types, status indicators,
  15. and UI elements. Icons use Nerd Font glyphs for consistent display.
  16. Categories:
  17. - File types: .yaml, .j2, .json, .md, etc.
  18. - Status: success, warning, error, info, skipped
  19. - UI elements: folders, config, locks, etc.
  20. """
  21. # File Type Icons
  22. FILE_FOLDER = "\uf07b" #
  23. FILE_DEFAULT = "\uf15b" #
  24. FILE_YAML = "\uf15c" #
  25. FILE_JSON = "\ue60b" #
  26. FILE_MARKDOWN = "\uf48a" #
  27. FILE_JINJA2 = "\ue235" #
  28. FILE_DOCKER = "\uf308" #
  29. FILE_COMPOSE = "\uf308" #
  30. FILE_SHELL = "\uf489" #
  31. FILE_PYTHON = "\ue73c" #
  32. FILE_TEXT = "\uf15c" #
  33. # Status Indicators
  34. STATUS_SUCCESS = "\uf00c" # (check)
  35. STATUS_ERROR = "\uf00d" # (times/x)
  36. STATUS_WARNING = "\uf071" # (exclamation-triangle)
  37. STATUS_INFO = "\uf05a" # (info-circle)
  38. STATUS_SKIPPED = "\uf05e" # (ban/circle-slash)
  39. # UI Elements
  40. UI_CONFIG = "\ue5fc" #
  41. UI_LOCK = "\uf084" #
  42. UI_SETTINGS = "\uf013" #
  43. UI_ARROW_RIGHT = "\uf061" # (arrow-right)
  44. UI_BULLET = "\uf111" # (circle)
  45. @classmethod
  46. def get_file_icon(cls, file_path: str | Path) -> str:
  47. """Get the appropriate icon for a file based on its extension or name.
  48. Args:
  49. file_path: Path to the file (can be string or Path object)
  50. Returns:
  51. Unicode icon character for the file type
  52. Examples:
  53. >>> IconManager.get_file_icon("config.yaml")
  54. '\uf15c'
  55. >>> IconManager.get_file_icon("template.j2")
  56. '\ue235'
  57. """
  58. if isinstance(file_path, str):
  59. file_path = Path(file_path)
  60. file_name = file_path.name.lower()
  61. suffix = file_path.suffix.lower()
  62. # Check for Docker Compose files
  63. compose_names = {
  64. "docker-compose.yml", "docker-compose.yaml",
  65. "compose.yml", "compose.yaml"
  66. }
  67. if file_name in compose_names or file_name.startswith("docker-compose"):
  68. return cls.FILE_DOCKER
  69. # Check by extension
  70. extension_map = {
  71. ".yaml": cls.FILE_YAML,
  72. ".yml": cls.FILE_YAML,
  73. ".json": cls.FILE_JSON,
  74. ".md": cls.FILE_MARKDOWN,
  75. ".j2": cls.FILE_JINJA2,
  76. ".sh": cls.FILE_SHELL,
  77. ".py": cls.FILE_PYTHON,
  78. ".txt": cls.FILE_TEXT,
  79. }
  80. return extension_map.get(suffix, cls.FILE_DEFAULT)
  81. @classmethod
  82. def get_status_icon(cls, status: str) -> str:
  83. """Get the appropriate icon for a status indicator.
  84. Args:
  85. status: Status type (success, error, warning, info, skipped)
  86. Returns:
  87. Unicode icon character for the status
  88. Examples:
  89. >>> IconManager.get_status_icon("success")
  90. '✓'
  91. >>> IconManager.get_status_icon("warning")
  92. '⚠'
  93. """
  94. status_map = {
  95. "success": cls.STATUS_SUCCESS,
  96. "error": cls.STATUS_ERROR,
  97. "warning": cls.STATUS_WARNING,
  98. "info": cls.STATUS_INFO,
  99. "skipped": cls.STATUS_SKIPPED,
  100. }
  101. return status_map.get(status.lower(), cls.STATUS_INFO)
  102. @classmethod
  103. def folder(cls) -> str:
  104. """Get the folder icon."""
  105. return cls.FILE_FOLDER
  106. @classmethod
  107. def config(cls) -> str:
  108. """Get the config icon."""
  109. return cls.UI_CONFIG
  110. @classmethod
  111. def lock(cls) -> str:
  112. """Get the lock icon (for sensitive variables)."""
  113. return cls.UI_LOCK
  114. @classmethod
  115. def arrow_right(cls) -> str:
  116. """Get the right arrow icon (for showing transitions/changes)."""
  117. return cls.UI_ARROW_RIGHT
  118. class DisplayManager:
  119. """Handles all rich rendering for the CLI."""
  120. def display_templates_table(
  121. self, templates: list, module_name: str, title: str
  122. ) -> None:
  123. """Display a table of templates.
  124. Args:
  125. templates: List of Template objects
  126. module_name: Name of the module
  127. title: Title for the table
  128. """
  129. if not templates:
  130. logger.info(f"No templates found for module '{module_name}'")
  131. return
  132. logger.info(f"Listing {len(templates)} templates for module '{module_name}'")
  133. table = Table(title=title)
  134. table.add_column("ID", style="bold", no_wrap=True)
  135. table.add_column("Name")
  136. table.add_column("Tags")
  137. table.add_column("Version", no_wrap=True)
  138. table.add_column("Library", no_wrap=True)
  139. for template in templates:
  140. name = template.metadata.name or "Unnamed Template"
  141. tags_list = template.metadata.tags or []
  142. tags = ", ".join(tags_list) if tags_list else "-"
  143. version = str(template.metadata.version) if template.metadata.version else ""
  144. library = template.metadata.library or ""
  145. table.add_row(template.id, name, tags, version, library)
  146. console.print(table)
  147. def display_template_details(self, template: Template, template_id: str) -> None:
  148. """Display template information panel and variables table."""
  149. self._display_template_header(template, template_id)
  150. self._display_file_tree(template)
  151. self._display_variables_table(template)
  152. def display_section_header(self, title: str, description: str | None) -> None:
  153. """Display a section header."""
  154. if description:
  155. console.print(f"\n[bold cyan]{title}[/bold cyan] [dim]- {description}[/dim]")
  156. else:
  157. console.print(f"\n[bold cyan]{title}[/bold cyan]")
  158. console.print("─" * 40, style="dim")
  159. def display_validation_error(self, message: str) -> None:
  160. """Display a validation error message."""
  161. self.display_message('error', message)
  162. def display_message(self, level: str, message: str, context: str | None = None) -> None:
  163. """Display a message with consistent formatting.
  164. Args:
  165. level: Message level (error, warning, success, info)
  166. message: The message to display
  167. context: Optional context information
  168. """
  169. icon = IconManager.get_status_icon(level)
  170. colors = {'error': 'red', 'warning': 'yellow', 'success': 'green', 'info': 'blue'}
  171. color = colors.get(level, 'white')
  172. # Format message based on context
  173. if context:
  174. text = f"{level.capitalize()} in {context}: {message}" if level == 'error' or level == 'warning' else f"{context}: {message}"
  175. else:
  176. text = f"{level.capitalize()}: {message}" if level == 'error' or level == 'warning' else message
  177. console.print(f"[{color}]{icon} {text}[/{color}]")
  178. # Log appropriately
  179. log_message = f"{context}: {message}" if context else message
  180. log_methods = {'error': logger.error, 'warning': logger.warning, 'success': logger.info, 'info': logger.info}
  181. log_methods.get(level, logger.info)(log_message)
  182. def display_error(self, message: str, context: str | None = None) -> None:
  183. """Display an error message."""
  184. self.display_message('error', message, context)
  185. def display_warning(self, message: str, context: str | None = None) -> None:
  186. """Display a warning message."""
  187. self.display_message('warning', message, context)
  188. def display_success(self, message: str, context: str | None = None) -> None:
  189. """Display a success message."""
  190. self.display_message('success', message, context)
  191. def display_info(self, message: str, context: str | None = None) -> None:
  192. """Display an informational message."""
  193. self.display_message('info', message, context)
  194. def _display_template_header(self, template: Template, template_id: str) -> None:
  195. """Display the header for a template."""
  196. template_name = template.metadata.name or "Unnamed Template"
  197. version = str(template.metadata.version) if template.metadata.version else "Not specified"
  198. description = template.metadata.description or "No description available"
  199. console.print(
  200. f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan])[/bold blue]"
  201. )
  202. console.print(description)
  203. def _build_file_tree(self, root_label: str, files: list, get_file_info: callable) -> Tree:
  204. """Build a file tree structure.
  205. Args:
  206. root_label: Label for root node
  207. files: List of files to display
  208. get_file_info: Function that takes a file and returns (path, display_name, color, extra_text)
  209. Returns:
  210. Tree object ready for display
  211. """
  212. file_tree = Tree(root_label)
  213. tree_nodes = {Path("."): file_tree}
  214. for file_item in sorted(files, key=lambda f: get_file_info(f)[0]):
  215. path, display_name, color, extra_text = get_file_info(file_item)
  216. parts = path.parts
  217. current_path = Path(".")
  218. current_node = file_tree
  219. # Build directory structure
  220. for part in parts[:-1]:
  221. current_path = current_path / part
  222. if current_path not in tree_nodes:
  223. new_node = current_node.add(f"{IconManager.folder()} [white]{part}[/white]")
  224. tree_nodes[current_path] = new_node
  225. current_node = tree_nodes[current_path]
  226. # Add file
  227. icon = IconManager.get_file_icon(display_name)
  228. file_label = f"{icon} [{color}]{display_name}[/{color}]"
  229. if extra_text:
  230. file_label += f" {extra_text}"
  231. current_node.add(file_label)
  232. return file_tree
  233. def _display_file_tree(self, template: Template) -> None:
  234. """Display the file structure of a template."""
  235. console.print()
  236. console.print("[bold blue]Template File Structure:[/bold blue]")
  237. def get_template_file_info(template_file):
  238. display_name = template_file.output_path.name if hasattr(template_file, 'output_path') else template_file.relative_path.name
  239. return (template_file.relative_path, display_name, 'white', None)
  240. file_tree = self._build_file_tree(
  241. f"{IconManager.folder()} [white]{template.id}[/white]",
  242. template.template_files,
  243. get_template_file_info
  244. )
  245. if file_tree.children:
  246. console.print(file_tree)
  247. def _display_variables_table(self, template: Template) -> None:
  248. """Display a table of variables for a template."""
  249. if not (template.variables and template.variables.has_sections()):
  250. return
  251. console.print()
  252. console.print("[bold blue]Template Variables:[/bold blue]")
  253. variables_table = Table(show_header=True, header_style="bold blue")
  254. variables_table.add_column("Variable", style="white", no_wrap=True)
  255. variables_table.add_column("Type", style="magenta")
  256. variables_table.add_column("Default", style="green")
  257. variables_table.add_column("Description", style="white")
  258. first_section = True
  259. for section in template.variables.get_sections().values():
  260. if not section.variables:
  261. continue
  262. if not first_section:
  263. variables_table.add_row("", "", "", "", style="bright_black")
  264. first_section = False
  265. # Check if section is enabled AND dependencies are satisfied
  266. is_enabled = section.is_enabled()
  267. dependencies_satisfied = template.variables.is_section_satisfied(section.key)
  268. is_dimmed = not (is_enabled and dependencies_satisfied)
  269. # Only show (disabled) if section has no dependencies (dependencies make it obvious)
  270. disabled_text = " (disabled)" if (is_dimmed and not section.needs) else ""
  271. # For disabled sections, make entire heading bold and dim (don't include colored markup inside)
  272. if is_dimmed:
  273. # Build text without internal markup, then wrap entire thing in bold bright_black (dimmed appearance)
  274. required_part = " (required)" if section.required else ""
  275. needs_part = ""
  276. if section.needs:
  277. needs_list = ", ".join(section.needs)
  278. needs_part = f" (needs: {needs_list})"
  279. header_text = f"[bold bright_black]{section.title}{required_part}{needs_part}{disabled_text}[/bold bright_black]"
  280. else:
  281. # For enabled sections, include the colored markup
  282. required_text = " [yellow](required)[/yellow]" if section.required else ""
  283. needs_text = ""
  284. if section.needs:
  285. needs_list = ", ".join(section.needs)
  286. needs_text = f" [dim](needs: {needs_list})[/dim]"
  287. header_text = f"[bold]{section.title}{required_text}{needs_text}{disabled_text}[/bold]"
  288. variables_table.add_row(header_text, "", "", "")
  289. for var_name, variable in section.variables.items():
  290. row_style = "bright_black" if is_dimmed else None
  291. # Build default value display
  292. # If origin is 'config' and original value differs from current, show: original → config_value
  293. if (variable.origin == "config" and
  294. hasattr(variable, '_original_stored') and
  295. variable.original_value != variable.value):
  296. # Format original value (use same display logic, but shorter)
  297. if variable.sensitive:
  298. orig_display = "********"
  299. elif variable.original_value is None or variable.original_value == "":
  300. orig_display = "[dim](none)[/dim]"
  301. else:
  302. orig_val_str = str(variable.original_value)
  303. orig_display = orig_val_str[:15] + "..." if len(orig_val_str) > 15 else orig_val_str
  304. # Get current (config) value display (without showing "(none)" since we have the arrow)
  305. config_display = variable.get_display_value(mask_sensitive=True, max_length=15, show_none=False)
  306. if not config_display: # If still empty after show_none=False, show actual value
  307. config_display = str(variable.value) if variable.value else "(empty)"
  308. # Highlight the arrow and config value in bold yellow to show it's a custom override
  309. default_val = f"{orig_display} [bold yellow]{IconManager.arrow_right()} {config_display}[/bold yellow]"
  310. else:
  311. # Use variable's native get_display_value() method (shows "(none)" for empty)
  312. default_val = variable.get_display_value(mask_sensitive=True, max_length=30, show_none=True)
  313. # Add lock icon for sensitive variables
  314. sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
  315. var_display = f" {var_name}{sensitive_icon}"
  316. variables_table.add_row(
  317. var_display,
  318. variable.type or "str",
  319. default_val,
  320. variable.description or "",
  321. style=row_style,
  322. )
  323. console.print(variables_table)
  324. def display_file_generation_confirmation(
  325. self,
  326. output_dir: Path,
  327. files: dict[str, str],
  328. existing_files: list[Path] | None = None
  329. ) -> None:
  330. """Display files to be generated with confirmation prompt."""
  331. console.print()
  332. console.print("[bold]Files to be generated:[/bold]")
  333. def get_file_generation_info(file_path_str):
  334. file_path = Path(file_path_str)
  335. file_name = file_path.parts[-1] if file_path.parts else file_path.name
  336. full_path = output_dir / file_path
  337. if existing_files and full_path in existing_files:
  338. return (file_path, file_name, 'yellow', '[red](will overwrite)[/red]')
  339. else:
  340. return (file_path, file_name, 'green', None)
  341. file_tree = self._build_file_tree(
  342. f"{IconManager.folder()} [cyan]{output_dir.resolve()}[/cyan]",
  343. files.keys(),
  344. get_file_generation_info
  345. )
  346. console.print(file_tree)
  347. console.print()
  348. def display_config_tree(self, spec: dict, module_name: str, show_all: bool = False) -> None:
  349. """Display configuration spec as a tree view.
  350. Args:
  351. spec: The configuration spec dictionary
  352. module_name: Name of the module
  353. show_all: If True, show all details including descriptions
  354. """
  355. if not spec:
  356. console.print(f"[yellow]No configuration found for module '{module_name}'[/yellow]")
  357. return
  358. # Create root tree node
  359. tree = Tree(f"[bold blue]{IconManager.config()} {str.capitalize(module_name)} Configuration[/bold blue]")
  360. for section_name, section_data in spec.items():
  361. if not isinstance(section_data, dict):
  362. continue
  363. # Determine if this is a section with variables
  364. # Guard against None from empty YAML sections
  365. section_vars = section_data.get("vars") or {}
  366. section_desc = section_data.get("description", "")
  367. section_required = section_data.get("required", False)
  368. section_toggle = section_data.get("toggle", None)
  369. section_needs = section_data.get("needs", None)
  370. # Build section label
  371. section_label = f"[cyan]{section_name}[/cyan]"
  372. if section_required:
  373. section_label += " [yellow](required)[/yellow]"
  374. if section_toggle:
  375. section_label += f" [dim](toggle: {section_toggle})[/dim]"
  376. if section_needs:
  377. needs_str = ", ".join(section_needs) if isinstance(section_needs, list) else section_needs
  378. section_label += f" [dim](needs: {needs_str})[/dim]"
  379. if show_all and section_desc:
  380. section_label += f"\n [dim]{section_desc}[/dim]"
  381. section_node = tree.add(section_label)
  382. # Add variables
  383. if section_vars:
  384. for var_name, var_data in section_vars.items():
  385. if isinstance(var_data, dict):
  386. var_type = var_data.get("type", "string")
  387. var_default = var_data.get("default", "")
  388. var_desc = var_data.get("description", "")
  389. var_sensitive = var_data.get("sensitive", False)
  390. # Build variable label
  391. var_label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
  392. if var_default is not None and var_default != "":
  393. display_val = "********" if var_sensitive else str(var_default)
  394. if not var_sensitive and len(display_val) > 30:
  395. display_val = display_val[:27] + "..."
  396. var_label += f" = [yellow]{display_val}[/yellow]"
  397. if show_all and var_desc:
  398. var_label += f"\n [dim]{var_desc}[/dim]"
  399. section_node.add(var_label)
  400. else:
  401. # Simple key-value pair
  402. section_node.add(f"[green]{var_name}[/green] = [yellow]{var_data}[/yellow]")
  403. console.print(tree)
  404. def display_next_steps(self, next_steps: str, variable_values: dict) -> None:
  405. """Display next steps after template generation, rendering them as a Jinja2 template.
  406. Args:
  407. next_steps: The next_steps string from template metadata (may contain Jinja2 syntax)
  408. variable_values: Dictionary of variable values to use for rendering
  409. """
  410. if not next_steps:
  411. return
  412. console.print("\n[bold cyan]Next Steps:[/bold cyan]")
  413. try:
  414. from jinja2 import Template as Jinja2Template
  415. next_steps_template = Jinja2Template(next_steps)
  416. rendered_next_steps = next_steps_template.render(variable_values)
  417. console.print(rendered_next_steps)
  418. except Exception as e:
  419. logger.warning(f"Failed to render next_steps as template: {e}")
  420. # Fallback to plain text if rendering fails
  421. console.print(next_steps)