__init__.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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.tree import Tree
  7. from .variable_display import VariableDisplayManager
  8. from .template_display import TemplateDisplayManager
  9. from .status_display import StatusDisplayManager
  10. from .table_display import TableDisplayManager
  11. if TYPE_CHECKING:
  12. from ..exceptions import TemplateRenderError
  13. from ..template import Template
  14. logger = logging.getLogger(__name__)
  15. console = Console()
  16. console_err = Console(stderr=True)
  17. class DisplaySettings:
  18. """Centralized display configuration settings.
  19. This class holds all configurable display parameters including colors,
  20. styles, layouts, and formatting options. Modify these values to customize
  21. the CLI appearance.
  22. """
  23. # === Color Scheme ===
  24. COLOR_ERROR = "red"
  25. COLOR_WARNING = "yellow"
  26. COLOR_SUCCESS = "green"
  27. COLOR_INFO = "blue"
  28. COLOR_MUTED = "dim"
  29. # Library type colors
  30. COLOR_LIBRARY_GIT = "blue"
  31. COLOR_LIBRARY_STATIC = "yellow"
  32. # === Style Constants ===
  33. STYLE_HEADER = "bold blue"
  34. STYLE_HEADER_ALT = "bold cyan"
  35. STYLE_DISABLED = "bright_black"
  36. STYLE_SECTION_TITLE = "bold cyan"
  37. STYLE_SECTION_DESC = "dim"
  38. # Table styles
  39. STYLE_TABLE_HEADER = "bold blue"
  40. STYLE_VAR_COL_NAME = "white"
  41. STYLE_VAR_COL_TYPE = "magenta"
  42. STYLE_VAR_COL_DEFAULT = "green"
  43. STYLE_VAR_COL_DESC = "white"
  44. # === Text Labels ===
  45. LABEL_REQUIRED = " [yellow](required)[/yellow]"
  46. LABEL_DISABLED = " (disabled)"
  47. TEXT_EMPTY_VALUE = "(none)"
  48. TEXT_EMPTY_OVERRIDE = "(empty)"
  49. TEXT_UNNAMED_TEMPLATE = "Unnamed Template"
  50. TEXT_NO_DESCRIPTION = "No description available"
  51. TEXT_VERSION_NOT_SPECIFIED = "Not specified"
  52. # === Value Formatting ===
  53. SENSITIVE_MASK = "********"
  54. TRUNCATION_SUFFIX = "..."
  55. VALUE_MAX_LENGTH_SHORT = 15
  56. VALUE_MAX_LENGTH_DEFAULT = 30
  57. # === Layout Constants ===
  58. SECTION_SEPARATOR_CHAR = "─"
  59. SECTION_SEPARATOR_LENGTH = 40
  60. VAR_NAME_INDENT = " " # 2 spaces
  61. # === Size Formatting ===
  62. SIZE_KB_THRESHOLD = 1024
  63. SIZE_MB_THRESHOLD = 1024 * 1024
  64. SIZE_DECIMAL_PLACES = 1
  65. # === Table Padding ===
  66. PADDING_PANEL = (1, 2)
  67. PADDING_TABLE_COMPACT = (0, 1)
  68. PADDING_TABLE_NORMAL = (0, 2)
  69. class IconManager:
  70. """Centralized icon management system for consistent CLI display.
  71. This class provides standardized icons for file types, status indicators,
  72. and UI elements. Icons use Nerd Font glyphs for consistent display.
  73. Categories:
  74. - File types: .yaml, .j2, .json, .md, etc.
  75. - Status: success, warning, error, info, skipped
  76. - UI elements: folders, config, locks, etc.
  77. """
  78. # File Type Icons
  79. FILE_FOLDER = "\uf07b" #
  80. FILE_DEFAULT = "\uf15b" #
  81. FILE_YAML = "\uf15c" #
  82. FILE_JSON = "\ue60b" #
  83. FILE_MARKDOWN = "\uf48a" #
  84. FILE_JINJA2 = "\ue235" #
  85. FILE_DOCKER = "\uf308" #
  86. FILE_COMPOSE = "\uf308" #
  87. FILE_SHELL = "\uf489" #
  88. FILE_PYTHON = "\ue73c" #
  89. FILE_TEXT = "\uf15c" #
  90. # Status Indicators
  91. STATUS_SUCCESS = "\uf00c" # (check)
  92. STATUS_ERROR = "\uf00d" # (times/x)
  93. STATUS_WARNING = "\uf071" # (exclamation-triangle)
  94. STATUS_INFO = "\uf05a" # (info-circle)
  95. STATUS_SKIPPED = "\uf05e" # (ban/circle-slash)
  96. # UI Elements
  97. UI_CONFIG = "\ue5fc" #
  98. UI_LOCK = "\uf084" #
  99. UI_SETTINGS = "\uf013" #
  100. UI_ARROW_RIGHT = "\uf061" # (arrow-right)
  101. UI_BULLET = "\uf111" # (circle)
  102. UI_LIBRARY_GIT = "\uf418" # (git icon)
  103. UI_LIBRARY_STATIC = "\uf07c" # (folder icon)
  104. @classmethod
  105. def get_file_icon(cls, file_path: str | Path) -> str:
  106. """Get the appropriate icon for a file based on its extension or name.
  107. Args:
  108. file_path: Path to the file (can be string or Path object)
  109. Returns:
  110. Unicode icon character for the file type
  111. Examples:
  112. >>> IconManager.get_file_icon("config.yaml")
  113. '\uf15c'
  114. >>> IconManager.get_file_icon("template.j2")
  115. '\ue235'
  116. """
  117. if isinstance(file_path, str):
  118. file_path = Path(file_path)
  119. file_name = file_path.name.lower()
  120. suffix = file_path.suffix.lower()
  121. # Check for Docker Compose files
  122. compose_names = {
  123. "docker-compose.yml",
  124. "docker-compose.yaml",
  125. "compose.yml",
  126. "compose.yaml",
  127. }
  128. if file_name in compose_names or file_name.startswith("docker-compose"):
  129. return cls.FILE_DOCKER
  130. # Check by extension
  131. extension_map = {
  132. ".yaml": cls.FILE_YAML,
  133. ".yml": cls.FILE_YAML,
  134. ".json": cls.FILE_JSON,
  135. ".md": cls.FILE_MARKDOWN,
  136. ".j2": cls.FILE_JINJA2,
  137. ".sh": cls.FILE_SHELL,
  138. ".py": cls.FILE_PYTHON,
  139. ".txt": cls.FILE_TEXT,
  140. }
  141. return extension_map.get(suffix, cls.FILE_DEFAULT)
  142. @classmethod
  143. def get_status_icon(cls, status: str) -> str:
  144. """Get the appropriate icon for a status indicator.
  145. Args:
  146. status: Status type (success, error, warning, info, skipped)
  147. Returns:
  148. Unicode icon character for the status
  149. Examples:
  150. >>> IconManager.get_status_icon("success")
  151. '✓'
  152. >>> IconManager.get_status_icon("warning")
  153. '⚠'
  154. """
  155. status_map = {
  156. "success": cls.STATUS_SUCCESS,
  157. "error": cls.STATUS_ERROR,
  158. "warning": cls.STATUS_WARNING,
  159. "info": cls.STATUS_INFO,
  160. "skipped": cls.STATUS_SKIPPED,
  161. }
  162. return status_map.get(status.lower(), cls.STATUS_INFO)
  163. @classmethod
  164. def folder(cls) -> str:
  165. """Get the folder icon."""
  166. return cls.FILE_FOLDER
  167. @classmethod
  168. def config(cls) -> str:
  169. """Get the config icon."""
  170. return cls.UI_CONFIG
  171. @classmethod
  172. def lock(cls) -> str:
  173. """Get the lock icon (for sensitive variables)."""
  174. return cls.UI_LOCK
  175. @classmethod
  176. def arrow_right(cls) -> str:
  177. """Get the right arrow icon (for showing transitions/changes)."""
  178. return cls.UI_ARROW_RIGHT
  179. class DisplayManager:
  180. """Main display coordinator with shared resources.
  181. This class acts as a facade that delegates to specialized display managers.
  182. External code should use DisplayManager methods which provide backward
  183. compatibility while internally using the specialized managers.
  184. Design Principles:
  185. - All display logic should go through DisplayManager methods
  186. - IconManager is ONLY used internally by display managers
  187. - External code should never directly call IconManager or console.print
  188. - Consistent formatting across all display types
  189. """
  190. def __init__(self, quiet: bool = False, settings: DisplaySettings | None = None):
  191. """Initialize DisplayManager with specialized sub-managers.
  192. Args:
  193. quiet: If True, suppress all non-error output
  194. settings: Optional DisplaySettings instance for customization
  195. """
  196. self.quiet = quiet
  197. self.settings = settings or DisplaySettings()
  198. # Initialize specialized managers
  199. self.variables = VariableDisplayManager(self)
  200. self.templates = TemplateDisplayManager(self)
  201. self.status = StatusDisplayManager(self)
  202. self.tables = TableDisplayManager(self)
  203. # ===== Shared Helper Methods =====
  204. def _format_library_display(self, library_name: str, library_type: str) -> str:
  205. """Format library name with appropriate icon and color.
  206. Args:
  207. library_name: Name of the library
  208. library_type: Type of library ('static' or 'git')
  209. Returns:
  210. Formatted library display string with Rich markup
  211. """
  212. if library_type == "static":
  213. color = self.settings.COLOR_LIBRARY_STATIC
  214. icon = IconManager.UI_LIBRARY_STATIC
  215. else:
  216. color = self.settings.COLOR_LIBRARY_GIT
  217. icon = IconManager.UI_LIBRARY_GIT
  218. return f"[{color}]{icon} {library_name}[/{color}]"
  219. def _truncate_value(self, value: str, max_length: int | None = None) -> str:
  220. """Truncate a string value if it exceeds maximum length.
  221. Args:
  222. value: String value to truncate
  223. max_length: Maximum length (uses default if None)
  224. Returns:
  225. Truncated string with suffix if needed
  226. """
  227. if max_length is None:
  228. max_length = self.settings.VALUE_MAX_LENGTH_DEFAULT
  229. if max_length > 0 and len(value) > max_length:
  230. return value[: max_length - len(self.settings.TRUNCATION_SUFFIX)] + self.settings.TRUNCATION_SUFFIX
  231. return value
  232. def _format_file_size(self, size_bytes: int) -> str:
  233. """Format file size in human-readable format (B, KB, MB).
  234. Args:
  235. size_bytes: Size in bytes
  236. Returns:
  237. Formatted size string (e.g., "1.5KB", "2.3MB")
  238. """
  239. if size_bytes < self.settings.SIZE_KB_THRESHOLD:
  240. return f"{size_bytes}B"
  241. elif size_bytes < self.settings.SIZE_MB_THRESHOLD:
  242. kb = size_bytes / self.settings.SIZE_KB_THRESHOLD
  243. return f"{kb:.{self.settings.SIZE_DECIMAL_PLACES}f}KB"
  244. else:
  245. mb = size_bytes / self.settings.SIZE_MB_THRESHOLD
  246. return f"{mb:.{self.settings.SIZE_DECIMAL_PLACES}f}MB"
  247. # ===== Backward Compatibility Delegation Methods =====
  248. # These methods delegate to specialized managers for backward compatibility
  249. def display_templates_table(
  250. self, templates: list, module_name: str, title: str
  251. ) -> None:
  252. """Delegate to TableDisplayManager."""
  253. return self.tables.render_templates_table(templates, module_name, title)
  254. def display_template(self, template: "Template", template_id: str) -> None:
  255. """Delegate to TemplateDisplayManager."""
  256. return self.templates.render_template(template, template_id)
  257. def display_section(self, title: str, description: str | None) -> None:
  258. """Delegate to VariableDisplayManager."""
  259. return self.variables.render_section(title, description)
  260. def display_validation_error(self, message: str) -> None:
  261. """Delegate to StatusDisplayManager."""
  262. return self.status.display_validation_error(message)
  263. def display_message(
  264. self, level: str, message: str, context: str | None = None
  265. ) -> None:
  266. """Delegate to StatusDisplayManager."""
  267. return self.status.display_message(level, message, context)
  268. def display_error(self, message: str, context: str | None = None) -> None:
  269. """Delegate to StatusDisplayManager."""
  270. return self.status.display_error(message, context)
  271. def display_warning(self, message: str, context: str | None = None) -> None:
  272. """Delegate to StatusDisplayManager."""
  273. return self.status.display_warning(message, context)
  274. def display_success(self, message: str, context: str | None = None) -> None:
  275. """Delegate to StatusDisplayManager."""
  276. return self.status.display_success(message, context)
  277. def display_info(self, message: str, context: str | None = None) -> None:
  278. """Delegate to StatusDisplayManager."""
  279. return self.status.display_info(message, context)
  280. def display_version_incompatibility(
  281. self, template_id: str, required_version: str, current_version: str
  282. ) -> None:
  283. """Delegate to StatusDisplayManager."""
  284. return self.status.display_version_incompatibility(
  285. template_id, required_version, current_version
  286. )
  287. def display_file_generation_confirmation(
  288. self,
  289. output_dir: Path,
  290. files: dict[str, str],
  291. existing_files: list[Path] | None = None,
  292. ) -> None:
  293. """Delegate to TemplateDisplayManager."""
  294. return self.templates.render_file_generation_confirmation(
  295. output_dir, files, existing_files
  296. )
  297. def display_config_tree(
  298. self, spec: dict, module_name: str, show_all: bool = False
  299. ) -> None:
  300. """Delegate to TableDisplayManager."""
  301. return self.tables.render_config_tree(spec, module_name, show_all)
  302. def display_status_table(
  303. self,
  304. title: str,
  305. rows: list[tuple[str, str, bool]],
  306. columns: tuple[str, str] = ("Item", "Status"),
  307. ) -> None:
  308. """Delegate to TableDisplayManager."""
  309. return self.tables.render_status_table(title, rows, columns)
  310. def display_summary_table(self, title: str, items: dict[str, str]) -> None:
  311. """Delegate to TableDisplayManager."""
  312. return self.tables.render_summary_table(title, items)
  313. def display_file_operation_table(self, files: list[tuple[str, int, str]]) -> None:
  314. """Delegate to TableDisplayManager."""
  315. return self.tables.render_file_operation_table(files)
  316. def display_warning_with_confirmation(
  317. self, message: str, details: list[str] | None = None, default: bool = False
  318. ) -> bool:
  319. """Delegate to StatusDisplayManager."""
  320. return self.status.display_warning_with_confirmation(message, details, default)
  321. def display_skipped(self, message: str, reason: str | None = None) -> None:
  322. """Delegate to StatusDisplayManager."""
  323. return self.status.display_skipped(message, reason)
  324. def display_template_render_error(
  325. self, error: "TemplateRenderError", context: str | None = None
  326. ) -> None:
  327. """Delegate to StatusDisplayManager."""
  328. return self.status.display_template_render_error(error, context)
  329. # ===== Internal Helper Methods =====
  330. def _render_file_tree_internal(
  331. self, root_label: str, files: list, get_file_info: callable
  332. ) -> Tree:
  333. """Render a file tree structure.
  334. Args:
  335. root_label: Label for root node
  336. files: List of files to display
  337. get_file_info: Function that takes a file and returns (path, display_name, color, extra_text)
  338. Returns:
  339. Tree object ready for display
  340. """
  341. file_tree = Tree(root_label)
  342. tree_nodes = {Path("."): file_tree}
  343. for file_item in sorted(files, key=lambda f: get_file_info(f)[0]):
  344. path, display_name, color, extra_text = get_file_info(file_item)
  345. parts = path.parts
  346. current_path = Path(".")
  347. current_node = file_tree
  348. # Build directory structure
  349. for part in parts[:-1]:
  350. current_path = current_path / part
  351. if current_path not in tree_nodes:
  352. new_node = current_node.add(
  353. f"{IconManager.folder()} [white]{part}[/white]"
  354. )
  355. tree_nodes[current_path] = new_node
  356. current_node = tree_nodes[current_path]
  357. # Add file
  358. icon = IconManager.get_file_icon(display_name)
  359. file_label = f"{icon} [{color}]{display_name}[/{color}]"
  360. if extra_text:
  361. file_label += f" {extra_text}"
  362. current_node.add(file_label)
  363. return file_tree
  364. # ===== Additional Methods =====
  365. def display_heading(
  366. self, text: str, icon_type: str | None = None, style: str = "bold"
  367. ) -> None:
  368. """Display a heading with optional icon.
  369. Args:
  370. text: Heading text
  371. icon_type: Type of icon to display (e.g., 'folder', 'file', 'config')
  372. style: Rich style to apply
  373. """
  374. if icon_type:
  375. icon = self._get_icon_by_type(icon_type)
  376. console.print(f"[{style}]{icon} {text}[/{style}]")
  377. else:
  378. console.print(f"[{style}]{text}[/{style}]")
  379. def get_lock_icon(self) -> str:
  380. """Get the lock icon for sensitive variables.
  381. Returns:
  382. Lock icon unicode character
  383. """
  384. return IconManager.lock()
  385. def _get_icon_by_type(self, icon_type: str) -> str:
  386. """Get icon by semantic type name.
  387. Args:
  388. icon_type: Type of icon (e.g., 'folder', 'file', 'config', 'lock')
  389. Returns:
  390. Icon unicode character
  391. """
  392. icon_map = {
  393. "folder": IconManager.folder(),
  394. "file": IconManager.FILE_DEFAULT,
  395. "config": IconManager.config(),
  396. "lock": IconManager.lock(),
  397. "arrow": IconManager.arrow_right(),
  398. }
  399. return icon_map.get(icon_type, "")
  400. def display_next_steps(self, next_steps: str, variable_values: dict) -> None:
  401. """Display next steps after template generation, rendering them as a Jinja2 template.
  402. Args:
  403. next_steps: The next_steps string from template metadata (may contain Jinja2 syntax)
  404. variable_values: Dictionary of variable values to use for rendering
  405. """
  406. if not next_steps:
  407. return
  408. console.print("\n[bold cyan]Next Steps:[/bold cyan]")
  409. try:
  410. from jinja2 import Template as Jinja2Template
  411. next_steps_template = Jinja2Template(next_steps)
  412. rendered_next_steps = next_steps_template.render(variable_values)
  413. console.print(rendered_next_steps)
  414. except Exception as e:
  415. logger.warning(f"Failed to render next_steps as template: {e}")
  416. # Fallback to plain text if rendering fails
  417. console.print(next_steps)
  418. # Export public API
  419. __all__ = [
  420. "DisplayManager",
  421. "DisplaySettings",
  422. "IconManager",
  423. "console",
  424. "console_err",
  425. ]