display.py 17 KB

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