module.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. from __future__ import annotations
  2. import logging
  3. from abc import ABC
  4. from pathlib import Path
  5. from typing import Any, Optional
  6. from rich.console import Console
  7. from rich.panel import Panel
  8. from rich.prompt import Confirm
  9. from rich.table import Table
  10. from rich.tree import Tree
  11. from typer import Argument, Context, Option, Typer
  12. from .library import LibraryManager
  13. from .prompt import PromptHandler
  14. from .template import Template
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. # -------------------------------
  18. # SECTION: Helper Functions
  19. # -------------------------------
  20. def parse_var_inputs(var_options: list[str], extra_args: list[str]) -> dict[str, Any]:
  21. """Parse variable inputs from --var options and extra args.
  22. Supports formats:
  23. --var KEY=VALUE
  24. --var KEY VALUE
  25. Args:
  26. var_options: List of variable options from CLI
  27. extra_args: Additional arguments that may contain values
  28. Returns:
  29. Dictionary of parsed variables
  30. """
  31. variables = {}
  32. # Parse --var KEY=VALUE format
  33. for var_option in var_options:
  34. if '=' in var_option:
  35. key, value = var_option.split('=', 1)
  36. variables[key] = value
  37. else:
  38. # --var KEY VALUE format - value should be in extra_args
  39. if extra_args:
  40. variables[var_option] = extra_args.pop(0)
  41. else:
  42. logger.warning(f"No value provided for variable '{var_option}'")
  43. return variables
  44. # !SECTION
  45. # ---------------------
  46. # SECTION: Module Class
  47. # ---------------------
  48. class Module(ABC):
  49. """Streamlined base module that auto-detects variables from templates."""
  50. def __init__(self) -> None:
  51. if not all([self.name, self.description]):
  52. raise ValueError(
  53. f"Module {self.__class__.__name__} must define name and description"
  54. )
  55. logger.info(f"Initializing module '{self.name}'")
  56. logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
  57. self.libraries = LibraryManager()
  58. # --------------------------
  59. # SECTION: Public Commands
  60. # --------------------------
  61. def list(
  62. self,
  63. filter_name: Optional[str] = Argument(None, help="Filter templates by name (e.g., 'traefik' shows traefik.*)"),
  64. all_templates: bool = Option(False, "--all", "-a", help="Show all templates including sub-templates")
  65. ) -> list[Template]:
  66. """List templates with optional filtering."""
  67. logger.debug(f"Listing templates for module '{self.name}' with filter='{filter_name}', all={all_templates}")
  68. templates = []
  69. entries = self.libraries.find(self.name, sort_results=True)
  70. for template_dir, library_name in entries:
  71. try:
  72. template = Template(template_dir, library_name=library_name)
  73. templates.append(template)
  74. except Exception as exc:
  75. logger.error(f"Failed to load template from {template_dir}: {exc}")
  76. continue
  77. # Apply filtering logic
  78. filtered_templates = self._filter_templates(templates, filter_name, all_templates)
  79. if filtered_templates:
  80. # Group templates for hierarchical display
  81. grouped_templates = self._group_templates(filtered_templates)
  82. logger.info(f"Listing {len(filtered_templates)} templates for module '{self.name}'")
  83. table = Table(title=f"{self.name.capitalize()} templates")
  84. table.add_column("ID", style="bold", no_wrap=True)
  85. table.add_column("Name")
  86. table.add_column("Description")
  87. table.add_column("Version", no_wrap=True)
  88. table.add_column("Library", no_wrap=True)
  89. for template_info in grouped_templates:
  90. template = template_info['template']
  91. indent = template_info['indent']
  92. name = template.metadata.name or 'Unnamed Template'
  93. desc = template.metadata.description or 'No description available'
  94. version = template.metadata.version or ''
  95. library = template.metadata.library or ''
  96. # Add indentation for sub-templates
  97. template_id = f"{indent}{template.id}"
  98. table.add_row(template_id, name, desc, version, library)
  99. console.print(table)
  100. else:
  101. filter_msg = f" matching '{filter_name}'" if filter_name else ""
  102. logger.info(f"No templates found for module '{self.name}'{filter_msg}")
  103. return filtered_templates
  104. def show(
  105. self,
  106. id: str,
  107. show_content: bool = False,
  108. ) -> None:
  109. """Show template details."""
  110. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  111. template = self._load_template_by_id(id)
  112. if not template:
  113. logger.warning(f"Template '{id}' not found in module '{self.name}'")
  114. console.print(f"[red]Template '{id}' not found in module '{self.name}'[/red]")
  115. return
  116. self._display_template_details(template, id)
  117. def generate(
  118. self,
  119. id: str = Argument(..., help="Template ID"),
  120. out: Optional[Path] = Option(None, "--out", "-o", help="Output directory"),
  121. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  122. var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  123. ctx: Context = None,
  124. ) -> None:
  125. """Generate from template."""
  126. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  127. template = self._load_template_by_id(id)
  128. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  129. cli_overrides = parse_var_inputs(var or [], extra_args)
  130. if cli_overrides:
  131. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  132. if template.variables:
  133. successful_overrides = template.variables.apply_overrides(cli_overrides, " -> cli")
  134. if successful_overrides:
  135. logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
  136. self._display_template_details(template, id)
  137. console.print()
  138. variable_values = {}
  139. if interactive and template.variables:
  140. prompt_handler = PromptHandler()
  141. collected_values = prompt_handler.collect_variables(template.variables)
  142. if collected_values:
  143. variable_values.update(collected_values)
  144. logger.info(f"Collected {len(collected_values)} variable values from user input")
  145. if template.variables:
  146. variable_values.update(template.variables.get_all_values())
  147. try:
  148. # Validate all variables before rendering
  149. if template.variables:
  150. template.variables.validate_all()
  151. rendered_files = template.render(template.variables)
  152. logger.info(f"Successfully rendered template '{id}'")
  153. output_dir = out or Path(".")
  154. # Check if the directory is empty and confirm overwrite if necessary
  155. if output_dir.exists() and any(output_dir.iterdir()):
  156. if interactive:
  157. if not Confirm.ask(f"Output directory '{output_dir}' is not empty. Overwrite files?", default=False):
  158. console.print("[yellow]Generation cancelled.[/yellow]")
  159. return
  160. else:
  161. logger.warning(f"Output directory '{output_dir}' is not empty. Existing files may be overwritten.")
  162. # Create the output directory if it doesn't exist
  163. output_dir.mkdir(parents=True, exist_ok=True)
  164. # Write rendered files to the output directory
  165. for file_path, content in rendered_files.items():
  166. full_path = output_dir / file_path
  167. full_path.parent.mkdir(parents=True, exist_ok=True)
  168. with open(full_path, 'w', encoding='utf-8') as f:
  169. f.write(content)
  170. console.print(f"[green]Generated file: {full_path}[/green]")
  171. logger.info(f"Template written to directory: {output_dir}")
  172. # If no output directory was specified, print the masked content to the console
  173. if not out:
  174. console.print("\n[bold]Rendered output (sensitive values masked):[/bold]")
  175. masked_files = template.mask_sensitive_values(rendered_files, template.variables)
  176. for file_path, content in masked_files.items():
  177. console.print(Panel(content, title=file_path, border_style="green"))
  178. except Exception as e:
  179. logger.error(f"Error rendering template '{id}': {e}")
  180. console.print(f"[red]Error generating template: {e}[/red]")
  181. raise
  182. # !SECTION
  183. # ------------------------------
  184. # SECTION: CLI Registration
  185. # ------------------------------
  186. @classmethod
  187. def register_cli(cls, app: Typer) -> None:
  188. """Register module commands with the main app."""
  189. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  190. module_instance = cls()
  191. module_app = Typer(help=cls.description)
  192. module_app.command("list")(module_instance.list)
  193. module_app.command("show")(module_instance.show)
  194. module_app.command(
  195. "generate",
  196. context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
  197. )(module_instance.generate)
  198. app.add_typer(module_app, name=cls.name, help=cls.description)
  199. logger.info(f"Module '{cls.name}' CLI commands registered")
  200. # !SECTION
  201. # --------------------------
  202. # SECTION: Template Organization Methods
  203. # --------------------------
  204. def _filter_templates(self, templates: list[Template], filter_name: Optional[str], all_templates: bool) -> list[Template]:
  205. """Filter templates based on name and sub-template visibility."""
  206. filtered = []
  207. for template in templates:
  208. template_id = template.id
  209. is_sub_template = '.' in template_id
  210. # If we have a filter, apply it
  211. if filter_name:
  212. if is_sub_template:
  213. # For sub-templates, check if they start with filter_name.
  214. if template_id.startswith(f"{filter_name}."):
  215. filtered.append(template)
  216. else:
  217. # For main templates, exact match
  218. if template_id == filter_name:
  219. filtered.append(template)
  220. else:
  221. # No filter - include based on all_templates flag
  222. if not all_templates and is_sub_template:
  223. continue
  224. filtered.append(template)
  225. return filtered
  226. def _group_templates(self, templates: list[Template]) -> list[dict]:
  227. """Group templates hierarchically for display."""
  228. grouped = []
  229. main_templates = {}
  230. sub_templates = []
  231. # Separate main templates and sub-templates
  232. for template in templates:
  233. if '.' in template.id:
  234. sub_templates.append(template)
  235. else:
  236. main_templates[template.id] = template
  237. grouped.append({
  238. 'template': template,
  239. 'indent': '',
  240. 'is_main': True
  241. })
  242. # Sort sub-templates by parent
  243. sub_templates.sort(key=lambda t: t.id)
  244. # Insert sub-templates after their parents
  245. for sub_template in sub_templates:
  246. parent_name = sub_template.id.split('.')[0]
  247. # Find where to insert this sub-template
  248. insert_index = -1
  249. for i, item in enumerate(grouped):
  250. if item['template'].id == parent_name:
  251. # Find the last sub-template for this parent
  252. j = i + 1
  253. while j < len(grouped) and not grouped[j]['is_main']:
  254. j += 1
  255. insert_index = j
  256. break
  257. sub_name = sub_template.id.split('.', 1)[1] # Get part after first dot
  258. sub_template_info = {
  259. 'template': sub_template,
  260. 'indent': '├─ ' if insert_index < len(grouped) - 1 else '└─ ',
  261. 'is_main': False
  262. }
  263. if insert_index >= 0:
  264. grouped.insert(insert_index, sub_template_info)
  265. else:
  266. # Parent not found, add at end
  267. grouped.append(sub_template_info)
  268. return grouped
  269. # !SECTION
  270. # --------------------------
  271. # SECTION: Private Methods
  272. # --------------------------
  273. def _load_template_by_id(self, template_id: str) -> Template:
  274. result = self.libraries.find_by_id(self.name, template_id)
  275. if not result:
  276. logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
  277. raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
  278. template_dir, library_name = result
  279. try:
  280. return Template(template_dir, library_name=library_name)
  281. except (ValueError, FileNotFoundError) as exc:
  282. raise FileNotFoundError(f"Template '{template_id}' validation failed in module '{self.name}'") from exc
  283. except Exception as exc:
  284. logger.error(f"Failed to load template from {template_dir}: {exc}")
  285. raise FileNotFoundError(f"Template '{template_id}' could not be loaded in module '{self.name}'") from exc
  286. def _display_template_details(self, template: Template, template_id: str) -> None:
  287. """Display template information panel and variables table."""
  288. # Build metadata info text
  289. info_lines = []
  290. info_lines.append(f"{template.metadata.description or 'No description available'}")
  291. info_lines.append("") # Empty line
  292. # Print template information with simple heading
  293. template_name = template.metadata.name or 'Unnamed Template'
  294. console.print(f"[bold blue]{template_name} ({template_id} - [cyan]{template.metadata.version or 'Not specified'}[/cyan])[/bold blue]")
  295. for line in info_lines:
  296. console.print(line)
  297. # Build the file structure tree
  298. file_tree = Tree("[bold blue]Template File Structure:[/bold blue]")
  299. # Create a dictionary to hold the tree nodes for directories
  300. # This will allow us to build a proper tree structure
  301. tree_nodes = {Path('.'): file_tree} # Root of the template directory
  302. for template_file in sorted(template.template_files, key=lambda f: f.relative_path):
  303. parts = template_file.relative_path.parts
  304. current_path = Path('.')
  305. current_node = file_tree
  306. # Build the directory path in the tree
  307. for part in parts[:-1]: # Iterate through directories
  308. current_path = current_path / part
  309. if current_path not in tree_nodes:
  310. new_node = current_node.add(f"\uf07b [bold blue]{part}[/bold blue]") # Folder icon
  311. tree_nodes[current_path] = new_node
  312. current_node = new_node
  313. else:
  314. current_node = tree_nodes[current_path]
  315. # Add the file to the appropriate directory node
  316. if template_file.file_type == 'j2':
  317. current_node.add(f"[green]\ue235 {template_file.relative_path.name}[/green]") # Jinja2 file icon
  318. elif template_file.file_type == 'static':
  319. current_node.add(f"[yellow]\uf15b {template_file.relative_path.name}[/yellow]") # Generic file icon
  320. # Print the file tree separately if it has content
  321. if file_tree.children: # Check if any files were added to the branches
  322. console.print() # Add spacing
  323. console.print(file_tree) # Print the Tree object directly
  324. if template.variables and template.variables.has_sections():
  325. console.print() # Add spacing
  326. # Print variables heading
  327. console.print(f"[bold blue]Template Variables:[/bold blue]")
  328. # Create variables table
  329. variables_table = Table(show_header=True, header_style="bold blue")
  330. variables_table.add_column("Variable", style="cyan", no_wrap=True)
  331. variables_table.add_column("Type", style="magenta")
  332. variables_table.add_column("Default", style="green")
  333. variables_table.add_column("Description", style="white")
  334. variables_table.add_column("Origin", style="yellow")
  335. # Add variables grouped by section
  336. first_section = True
  337. for section_key, section in template.variables.get_sections().items():
  338. if section.variables:
  339. # Add spacing between sections (except before first section)
  340. if not first_section:
  341. variables_table.add_row("", "", "", "", "", style="dim")
  342. first_section = False
  343. # Check if section should be dimmed (toggle is False)
  344. is_dimmed = False
  345. if section.toggle:
  346. toggle_var = section.variables.get(section.toggle)
  347. if toggle_var:
  348. # Get the actual typed value and check if it's falsy
  349. try:
  350. toggle_value = toggle_var.get_typed_value()
  351. if not toggle_value:
  352. is_dimmed = True
  353. except Exception as e:
  354. # Fallback to raw value check
  355. if not toggle_var.value:
  356. is_dimmed = True
  357. # Add section header row with proper styling
  358. disabled_text = " (disabled)" if is_dimmed else ""
  359. required_text = " [yellow](required)[/yellow]" if section.required else ""
  360. if is_dimmed:
  361. # Use Rich markup for dimmed bold text
  362. header_text = f"[bold dim]{section.title}{required_text}{disabled_text}[/bold dim]"
  363. else:
  364. # Use Rich markup for bold text
  365. header_text = f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
  366. variables_table.add_row(
  367. header_text,
  368. "", "", "", ""
  369. )
  370. # Add variables in this section
  371. for var_name, variable in section.variables.items():
  372. # Apply dim style to ALL variables if section toggle is False
  373. row_style = "dim" if is_dimmed else None
  374. # Format default value
  375. default_val = str(variable.value) if variable.value is not None else ""
  376. if variable.sensitive:
  377. default_val = "********"
  378. elif len(default_val) > 30:
  379. default_val = default_val[:27] + "..."
  380. variables_table.add_row(
  381. f" {var_name}",
  382. variable.type or "str",
  383. default_val,
  384. variable.description or "",
  385. variable.origin or "unknown",
  386. style=row_style
  387. )
  388. console.print(variables_table)
  389. # !SECTION