display.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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. console.print(f"[red]{message}[/red]")
  162. def _display_template_header(self, template: Template, template_id: str) -> None:
  163. """Display the header for a template."""
  164. template_name = template.metadata.name or "Unnamed Template"
  165. version = str(template.metadata.version) if template.metadata.version else "Not specified"
  166. description = template.metadata.description or "No description available"
  167. console.print(
  168. f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan])[/bold blue]"
  169. )
  170. console.print(description)
  171. def _display_file_tree(self, template: Template) -> None:
  172. """Display the file structure of a template."""
  173. # Preserve the heading, then use the template id as the root directory label
  174. console.print()
  175. console.print("[bold blue]Template File Structure:[/bold blue]")
  176. # Use the template id as the root directory label (folder glyph + white name)
  177. file_tree = Tree(f"{IconManager.folder()} [white]{template.id}[/white]")
  178. tree_nodes = {Path("."): file_tree}
  179. for template_file in sorted(
  180. template.template_files, key=lambda f: f.relative_path
  181. ):
  182. parts = template_file.relative_path.parts
  183. current_path = Path(".")
  184. current_node = file_tree
  185. for part in parts[:-1]:
  186. current_path = current_path / part
  187. if current_path not in tree_nodes:
  188. new_node = current_node.add(f"{IconManager.folder()} [white]{part}[/white]")
  189. tree_nodes[current_path] = new_node
  190. current_node = new_node
  191. else:
  192. current_node = tree_nodes[current_path]
  193. # Determine display name (use output_path to detect final filename)
  194. display_name = template_file.output_path.name if hasattr(template_file, 'output_path') else template_file.relative_path.name
  195. # Get appropriate icon based on file type/name
  196. icon = IconManager.get_file_icon(display_name)
  197. current_node.add(f"[white]{icon} {display_name}[/white]")
  198. if file_tree.children:
  199. console.print(file_tree)
  200. def _display_variables_table(self, template: Template) -> None:
  201. """Display a table of variables for a template."""
  202. if not (template.variables and template.variables.has_sections()):
  203. return
  204. console.print()
  205. console.print("[bold blue]Template Variables:[/bold blue]")
  206. variables_table = Table(show_header=True, header_style="bold blue")
  207. variables_table.add_column("Variable", style="cyan", no_wrap=True)
  208. variables_table.add_column("Type", style="magenta")
  209. variables_table.add_column("Default", style="green")
  210. variables_table.add_column("Description", style="white")
  211. first_section = True
  212. for section in template.variables.get_sections().values():
  213. if not section.variables:
  214. continue
  215. if not first_section:
  216. variables_table.add_row("", "", "", "", style="dim")
  217. first_section = False
  218. # Check if section is enabled AND dependencies are satisfied
  219. is_enabled = section.is_enabled()
  220. dependencies_satisfied = template.variables.is_section_satisfied(section.key)
  221. is_dimmed = not (is_enabled and dependencies_satisfied)
  222. # Only show (disabled) if section has no dependencies (dependencies make it obvious)
  223. disabled_text = " (disabled)" if (is_dimmed and not section.needs) else ""
  224. required_text = " [yellow](required)[/yellow]" if section.required else ""
  225. # Add dependency information
  226. needs_text = ""
  227. if section.needs:
  228. needs_list = ", ".join(section.needs)
  229. needs_text = f" [dim](needs: {needs_list})[/dim]"
  230. header_text = f"[bold dim]{section.title}{required_text}{needs_text}{disabled_text}[/bold dim]" if is_dimmed else f"[bold]{section.title}{required_text}{needs_text}{disabled_text}[/bold]"
  231. variables_table.add_row(header_text, "", "", "")
  232. for var_name, variable in section.variables.items():
  233. row_style = "dim" if is_dimmed else None
  234. # Build default value display
  235. # If origin is 'config' and original value differs from current, show: original → config_value
  236. if (variable.origin == "config" and
  237. hasattr(variable, '_original_stored') and
  238. variable.original_value != variable.value):
  239. # Format original value (mask if sensitive, show (none) if None)
  240. if variable.sensitive:
  241. orig_display = "********"
  242. elif variable.original_value is None:
  243. orig_display = "[dim](none)[/dim]"
  244. else:
  245. orig_val_str = str(variable.original_value)
  246. orig_display = orig_val_str[:15] + "..." if len(orig_val_str) > 15 else orig_val_str
  247. # Get current (config) value display
  248. config_display = variable.get_display_value(mask_sensitive=True, max_length=15)
  249. # Highlight the arrow and config value in yellow to show it's a custom override
  250. default_val = f"{orig_display} [yellow]{IconManager.arrow_right()} {config_display}[/yellow]"
  251. else:
  252. # Use variable's native get_display_value() method
  253. default_val = variable.get_display_value(mask_sensitive=True, max_length=30)
  254. # Add lock icon for sensitive variables
  255. sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
  256. var_display = f" {var_name}{sensitive_icon}"
  257. variables_table.add_row(
  258. var_display,
  259. variable.type or "str",
  260. default_val,
  261. variable.description or "",
  262. style=row_style,
  263. )
  264. console.print(variables_table)
  265. def display_file_generation_confirmation(
  266. self,
  267. output_dir: Path,
  268. files: dict[str, str],
  269. existing_files: list[Path] | None = None
  270. ) -> None:
  271. """Display files to be generated with confirmation prompt.
  272. Args:
  273. output_dir: The output directory path
  274. files: Dictionary of file paths to content
  275. existing_files: List of existing files that will be overwritten (if any)
  276. """
  277. console.print()
  278. console.print("[bold]Files to be generated:[/bold]")
  279. # Create a tree view of files
  280. file_tree = Tree(f"{IconManager.folder()} [cyan]{output_dir.resolve()}[/cyan]")
  281. tree_nodes = {Path("."): file_tree}
  282. # Sort files for better display
  283. sorted_files = sorted(files.keys())
  284. for file_path_str in sorted_files:
  285. file_path = Path(file_path_str)
  286. parts = file_path.parts
  287. current_path = Path(".")
  288. current_node = file_tree
  289. # Build directory structure
  290. for part in parts[:-1]:
  291. current_path = current_path / part
  292. if current_path not in tree_nodes:
  293. new_node = current_node.add(f"{IconManager.folder()} [white]{part}[/white]")
  294. tree_nodes[current_path] = new_node
  295. current_node = tree_nodes[current_path]
  296. # Add file with indicator if it will be overwritten
  297. file_name = parts[-1]
  298. full_path = output_dir / file_path
  299. icon = IconManager.get_file_icon(file_name)
  300. if existing_files and full_path in existing_files:
  301. current_node.add(f"{icon} [yellow]{file_name}[/yellow] [red](will overwrite)[/red]")
  302. else:
  303. current_node.add(f"{icon} [green]{file_name}[/green]")
  304. console.print(file_tree)
  305. console.print()
  306. def display_config_tree(self, spec: dict, module_name: str, show_all: bool = False) -> None:
  307. """Display configuration spec as a tree view.
  308. Args:
  309. spec: The configuration spec dictionary
  310. module_name: Name of the module
  311. show_all: If True, show all details including descriptions
  312. """
  313. if not spec:
  314. console.print(f"[yellow]No configuration found for module '{module_name}'[/yellow]")
  315. return
  316. # Create root tree node
  317. tree = Tree(f"[bold blue]{IconManager.config()} {str.capitalize(module_name)} Configuration[/bold blue]")
  318. for section_name, section_data in spec.items():
  319. if not isinstance(section_data, dict):
  320. continue
  321. # Determine if this is a section with variables
  322. # Guard against None from empty YAML sections
  323. section_vars = section_data.get("vars") or {}
  324. section_desc = section_data.get("description", "")
  325. section_required = section_data.get("required", False)
  326. section_toggle = section_data.get("toggle", None)
  327. section_needs = section_data.get("needs", None)
  328. # Build section label
  329. section_label = f"[cyan]{section_name}[/cyan]"
  330. if section_required:
  331. section_label += " [yellow](required)[/yellow]"
  332. if section_toggle:
  333. section_label += f" [dim](toggle: {section_toggle})[/dim]"
  334. if section_needs:
  335. needs_str = ", ".join(section_needs) if isinstance(section_needs, list) else section_needs
  336. section_label += f" [dim](needs: {needs_str})[/dim]"
  337. if show_all and section_desc:
  338. section_label += f"\n [dim]{section_desc}[/dim]"
  339. section_node = tree.add(section_label)
  340. # Add variables
  341. if section_vars:
  342. for var_name, var_data in section_vars.items():
  343. if isinstance(var_data, dict):
  344. var_type = var_data.get("type", "string")
  345. var_default = var_data.get("default", "")
  346. var_desc = var_data.get("description", "")
  347. var_sensitive = var_data.get("sensitive", False)
  348. # Build variable label
  349. var_label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
  350. if var_default is not None and var_default != "":
  351. display_val = "********" if var_sensitive else str(var_default)
  352. if not var_sensitive and len(display_val) > 30:
  353. display_val = display_val[:27] + "..."
  354. var_label += f" = [yellow]{display_val}[/yellow]"
  355. if show_all and var_desc:
  356. var_label += f"\n [dim]{var_desc}[/dim]"
  357. section_node.add(var_label)
  358. else:
  359. # Simple key-value pair
  360. section_node.add(f"[green]{var_name}[/green] = [yellow]{var_data}[/yellow]")
  361. console.print(tree)
  362. def display_next_steps(self, next_steps: str, variable_values: dict) -> None:
  363. """Display next steps after template generation, rendering them as a Jinja2 template.
  364. Args:
  365. next_steps: The next_steps string from template metadata (may contain Jinja2 syntax)
  366. variable_values: Dictionary of variable values to use for rendering
  367. """
  368. if not next_steps:
  369. return
  370. console.print("\n[bold cyan]Next Steps:[/bold cyan]")
  371. try:
  372. from jinja2 import Template as Jinja2Template
  373. next_steps_template = Jinja2Template(next_steps)
  374. rendered_next_steps = next_steps_template.render(variable_values)
  375. console.print(rendered_next_steps)
  376. except Exception as e:
  377. logger.warning(f"Failed to render next_steps as template: {e}")
  378. # Fallback to plain text if rendering fails
  379. console.print(next_steps)