module.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  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 typer import Argument, Context, Option, Typer, Exit
  10. from .display import DisplayManager
  11. from .library import LibraryManager
  12. from .prompt import PromptHandler
  13. from .template import Template
  14. logger = logging.getLogger(__name__)
  15. console = Console()
  16. # -------------------------------
  17. # SECTION: Helper Functions
  18. # -------------------------------
  19. def parse_var_inputs(var_options: list[str], extra_args: list[str]) -> dict[str, Any]:
  20. """Parse variable inputs from --var options and extra args.
  21. Supports formats:
  22. --var KEY=VALUE
  23. --var KEY VALUE
  24. Args:
  25. var_options: List of variable options from CLI
  26. extra_args: Additional arguments that may contain values
  27. Returns:
  28. Dictionary of parsed variables
  29. """
  30. variables = {}
  31. # Parse --var KEY=VALUE format
  32. for var_option in var_options:
  33. if '=' in var_option:
  34. key, value = var_option.split('=', 1)
  35. variables[key] = value
  36. else:
  37. # --var KEY VALUE format - value should be in extra_args
  38. if extra_args:
  39. variables[var_option] = extra_args.pop(0)
  40. else:
  41. logger.warning(f"No value provided for variable '{var_option}'")
  42. return variables
  43. # !SECTION
  44. # ---------------------
  45. # SECTION: Module Class
  46. # ---------------------
  47. class Module(ABC):
  48. """Streamlined base module that auto-detects variables from templates."""
  49. def __init__(self) -> None:
  50. if not all([self.name, self.description]):
  51. raise ValueError(
  52. f"Module {self.__class__.__name__} must define name and description"
  53. )
  54. logger.info(f"Initializing module '{self.name}'")
  55. logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
  56. self.libraries = LibraryManager()
  57. self.display = DisplayManager()
  58. # --------------------------
  59. # SECTION: Public Commands
  60. # --------------------------
  61. def list(
  62. self,
  63. all_templates: bool = Option(False, "--all", "-a", help="Show all templates including sub-templates")
  64. ) -> list[Template]:
  65. """List all templates."""
  66. logger.debug(f"Listing templates for module '{self.name}' with all={all_templates}")
  67. templates = []
  68. entries = self.libraries.find(self.name, sort_results=True)
  69. for template_dir, library_name in entries:
  70. try:
  71. template = Template(template_dir, library_name=library_name)
  72. templates.append(template)
  73. except Exception as exc:
  74. logger.error(f"Failed to load template from {template_dir}: {exc}")
  75. continue
  76. # Apply filtering logic
  77. filtered_templates = self._filter_templates(templates, None, all_templates)
  78. if filtered_templates:
  79. # Group templates for hierarchical display
  80. grouped_templates = self._group_templates(filtered_templates)
  81. self.display.display_templates_table(
  82. grouped_templates,
  83. self.name,
  84. f"{self.name.capitalize()} templates"
  85. )
  86. else:
  87. logger.info(f"No templates found for module '{self.name}'")
  88. return filtered_templates
  89. def search(
  90. self,
  91. query: str = Argument(..., help="Search string to filter templates by ID"),
  92. all_templates: bool = Option(False, "--all", "-a", help="Show all templates including sub-templates")
  93. ) -> list[Template]:
  94. """Search for templates by ID containing the search string."""
  95. logger.debug(f"Searching templates for module '{self.name}' with query='{query}', all={all_templates}")
  96. templates = []
  97. entries = self.libraries.find(self.name, sort_results=True)
  98. for template_dir, library_name in entries:
  99. try:
  100. template = Template(template_dir, library_name=library_name)
  101. templates.append(template)
  102. except Exception as exc:
  103. logger.error(f"Failed to load template from {template_dir}: {exc}")
  104. continue
  105. # Apply search filtering
  106. filtered_templates = self._search_templates(templates, query, all_templates)
  107. if filtered_templates:
  108. # Group templates for hierarchical display
  109. grouped_templates = self._group_templates(filtered_templates)
  110. logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{self.name}'")
  111. self.display.display_templates_table(
  112. grouped_templates,
  113. self.name,
  114. f"{self.name.capitalize()} templates matching '{query}'"
  115. )
  116. else:
  117. logger.info(f"No templates found matching '{query}' for module '{self.name}'")
  118. console.print(f"[yellow]No templates found matching '{query}' for module '{self.name}'[/yellow]")
  119. return filtered_templates
  120. def show(
  121. self,
  122. id: str,
  123. show_content: bool = False,
  124. ) -> None:
  125. """Show template details."""
  126. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  127. template = self._load_template_by_id(id)
  128. if not template:
  129. logger.warning(f"Template '{id}' not found in module '{self.name}'")
  130. console.print(f"[red]Template '{id}' not found in module '{self.name}'[/red]")
  131. return
  132. self._display_template_details(template, id)
  133. def generate(
  134. self,
  135. id: str = Argument(..., help="Template ID"),
  136. out: Optional[Path] = Option(None, "--out", "-o", help="Output directory"),
  137. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  138. var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  139. ctx: Context = None,
  140. ) -> None:
  141. """Generate from template."""
  142. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  143. template = self._load_template_by_id(id)
  144. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  145. cli_overrides = parse_var_inputs(var or [], extra_args)
  146. if cli_overrides:
  147. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  148. if template.variables:
  149. successful_overrides = template.variables.apply_overrides(cli_overrides, " -> cli")
  150. if successful_overrides:
  151. logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
  152. self._display_template_details(template, id)
  153. console.print()
  154. variable_values = {}
  155. if interactive and template.variables:
  156. prompt_handler = PromptHandler()
  157. collected_values = prompt_handler.collect_variables(template.variables)
  158. if collected_values:
  159. variable_values.update(collected_values)
  160. logger.info(f"Collected {len(collected_values)} variable values from user input")
  161. if template.variables:
  162. variable_values.update(template.variables.get_all_values())
  163. try:
  164. # Validate all variables before rendering
  165. if template.variables:
  166. template.variables.validate_all()
  167. rendered_files = template.render(template.variables)
  168. logger.info(f"Successfully rendered template '{id}'")
  169. output_dir = out or Path(".")
  170. # Check if the directory is empty and confirm overwrite if necessary
  171. if output_dir.exists() and any(output_dir.iterdir()):
  172. if interactive:
  173. if not Confirm.ask(f"Output directory '{output_dir}' is not empty. Overwrite files?", default=False):
  174. console.print("[yellow]Generation cancelled.[/yellow]")
  175. return
  176. else:
  177. logger.warning(f"Output directory '{output_dir}' is not empty. Existing files may be overwritten.")
  178. # Create the output directory if it doesn't exist
  179. output_dir.mkdir(parents=True, exist_ok=True)
  180. # Write rendered files to the output directory
  181. for file_path, content in rendered_files.items():
  182. full_path = output_dir / file_path
  183. full_path.parent.mkdir(parents=True, exist_ok=True)
  184. with open(full_path, 'w', encoding='utf-8') as f:
  185. f.write(content)
  186. console.print(f"[green]Generated file: {full_path}[/green]")
  187. logger.info(f"Template written to directory: {output_dir}")
  188. # If no output directory was specified, print the masked content to the console
  189. if not out:
  190. console.print("\n[bold]Rendered output (sensitive values masked):[/bold]")
  191. masked_files = template.mask_sensitive_values(rendered_files, template.variables)
  192. for file_path, content in masked_files.items():
  193. console.print(Panel(content, title=file_path, border_style="green"))
  194. except Exception as e:
  195. logger.error(f"Error rendering template '{id}': {e}")
  196. console.print(f"[red]Error generating template: {e}[/red]")
  197. # Stop execution without letting Typer/Click print the exception again.
  198. raise Exit(code=1)
  199. # !SECTION
  200. # ------------------------------
  201. # SECTION: CLI Registration
  202. # ------------------------------
  203. @classmethod
  204. def register_cli(cls, app: Typer) -> None:
  205. """Register module commands with the main app."""
  206. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  207. module_instance = cls()
  208. module_app = Typer(help=cls.description)
  209. module_app.command("list")(module_instance.list)
  210. module_app.command("search")(module_instance.search)
  211. module_app.command("show")(module_instance.show)
  212. module_app.command(
  213. "generate",
  214. context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
  215. )(module_instance.generate)
  216. app.add_typer(module_app, name=cls.name, help=cls.description)
  217. logger.info(f"Module '{cls.name}' CLI commands registered")
  218. # !SECTION
  219. # --------------------------
  220. # SECTION: Template Organization Methods
  221. # --------------------------
  222. def _filter_templates(self, templates: list[Template], filter_name: Optional[str], all_templates: bool) -> list[Template]:
  223. """Filter templates based on name and sub-template visibility."""
  224. filtered = []
  225. for template in templates:
  226. template_id = template.id
  227. is_sub_template = '.' in template_id
  228. # No filter - include based on all_templates flag
  229. if not all_templates and is_sub_template:
  230. continue
  231. filtered.append(template)
  232. return filtered
  233. def _search_templates(self, templates: list[Template], query: str, all_templates: bool) -> list[Template]:
  234. """Search templates by ID containing the query string."""
  235. filtered = []
  236. query_lower = query.lower()
  237. for template in templates:
  238. template_id = template.id
  239. is_sub_template = '.' in template_id
  240. # Skip sub-templates if not showing all
  241. if not all_templates and is_sub_template:
  242. continue
  243. # Check if query is contained in the template ID
  244. if query_lower in template_id.lower():
  245. filtered.append(template)
  246. return filtered
  247. def _group_templates(self, templates: list[Template]) -> list[dict]:
  248. """Group templates hierarchically for display."""
  249. grouped = []
  250. main_templates = {}
  251. sub_templates = []
  252. # Separate main templates and sub-templates
  253. for template in templates:
  254. if '.' in template.id:
  255. sub_templates.append(template)
  256. else:
  257. main_templates[template.id] = template
  258. grouped.append({
  259. 'template': template,
  260. 'indent': '',
  261. 'is_main': True
  262. })
  263. # Sort sub-templates by parent
  264. sub_templates.sort(key=lambda t: t.id)
  265. # Group sub-templates by parent for proper indentation
  266. sub_by_parent = {}
  267. for sub_template in sub_templates:
  268. parent_name = sub_template.id.split('.')[0]
  269. if parent_name not in sub_by_parent:
  270. sub_by_parent[parent_name] = []
  271. sub_by_parent[parent_name].append(sub_template)
  272. # Insert sub-templates after their parents with proper indentation
  273. for parent_name, parent_subs in sub_by_parent.items():
  274. # Find the parent in the grouped list
  275. insert_index = -1
  276. for i, item in enumerate(grouped):
  277. if item['template'].id == parent_name:
  278. insert_index = i + 1
  279. break
  280. # Add each sub-template with proper indentation
  281. for idx, sub_template in enumerate(parent_subs):
  282. is_last = (idx == len(parent_subs) - 1)
  283. sub_template_info = {
  284. 'template': sub_template,
  285. 'indent': '└─ ' if is_last else '├─ ',
  286. 'is_main': False
  287. }
  288. if insert_index >= 0:
  289. grouped.insert(insert_index, sub_template_info)
  290. insert_index += 1
  291. else:
  292. # Parent not found, add at end
  293. grouped.append(sub_template_info)
  294. return grouped
  295. # !SECTION
  296. # --------------------------
  297. # SECTION: Private Methods
  298. # --------------------------
  299. def _load_template_by_id(self, template_id: str) -> Template:
  300. result = self.libraries.find_by_id(self.name, template_id)
  301. if not result:
  302. logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
  303. raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
  304. template_dir, library_name = result
  305. try:
  306. return Template(template_dir, library_name=library_name)
  307. except (ValueError, FileNotFoundError) as exc:
  308. raise FileNotFoundError(f"Template '{template_id}' validation failed in module '{self.name}'") from exc
  309. except Exception as exc:
  310. logger.error(f"Failed to load template from {template_dir}: {exc}")
  311. raise FileNotFoundError(f"Template '{template_id}' could not be loaded in module '{self.name}'") from exc
  312. def _display_template_details(self, template: Template, template_id: str) -> None:
  313. """Display template information panel and variables table."""
  314. self.display.display_template_details(template, template_id)
  315. # !SECTION