module.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  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. # Apply config defaults (same as in generate)
  133. # This ensures the display shows the actual defaults that will be used
  134. if template.variables:
  135. from .config import ConfigManager
  136. config = ConfigManager()
  137. config_defaults = config.get_defaults(self.name)
  138. if config_defaults:
  139. logger.debug(f"Loading config defaults for module '{self.name}'")
  140. # Apply config defaults (this respects the variable types and validation)
  141. successful = template.variables.apply_defaults(config_defaults, "config")
  142. if successful:
  143. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  144. self._display_template_details(template, id)
  145. def generate(
  146. self,
  147. id: str = Argument(..., help="Template ID"),
  148. out: Optional[Path] = Option(None, "--out", "-o", help="Output directory"),
  149. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  150. var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  151. ctx: Context = None,
  152. ) -> None:
  153. """Generate from template.
  154. Variable precedence chain (lowest to highest):
  155. 1. Module spec (defined in cli/modules/*.py)
  156. 2. Template spec (from template.yaml)
  157. 3. Config defaults (from ~/.config/boilerplates/config.yaml)
  158. 4. CLI overrides (--var flags)
  159. """
  160. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  161. template = self._load_template_by_id(id)
  162. # Apply config defaults (precedence: config > template > module)
  163. # Config only sets VALUES, not the spec structure
  164. if template.variables:
  165. from .config import ConfigManager
  166. config = ConfigManager()
  167. config_defaults = config.get_defaults(self.name)
  168. if config_defaults:
  169. logger.info(f"Loading config defaults for module '{self.name}'")
  170. # Apply config defaults (this respects the variable types and validation)
  171. successful = template.variables.apply_defaults(config_defaults, "config")
  172. if successful:
  173. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  174. # Apply CLI overrides (highest precedence)
  175. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  176. cli_overrides = parse_var_inputs(var or [], extra_args)
  177. if cli_overrides:
  178. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  179. if template.variables:
  180. successful_overrides = template.variables.apply_defaults(cli_overrides, "cli")
  181. if successful_overrides:
  182. logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
  183. self._display_template_details(template, id)
  184. console.print()
  185. variable_values = {}
  186. if interactive and template.variables:
  187. prompt_handler = PromptHandler()
  188. collected_values = prompt_handler.collect_variables(template.variables)
  189. if collected_values:
  190. variable_values.update(collected_values)
  191. logger.info(f"Collected {len(collected_values)} variable values from user input")
  192. if template.variables:
  193. variable_values.update(template.variables.get_all_values())
  194. try:
  195. # Validate all variables before rendering
  196. if template.variables:
  197. template.variables.validate_all()
  198. rendered_files = template.render(template.variables)
  199. logger.info(f"Successfully rendered template '{id}'")
  200. output_dir = out or Path(".")
  201. # Check if the directory is empty and confirm overwrite if necessary
  202. if output_dir.exists() and any(output_dir.iterdir()):
  203. if interactive:
  204. if not Confirm.ask(f"Output directory '{output_dir}' is not empty. Overwrite files?", default=False):
  205. console.print("[yellow]Generation cancelled.[/yellow]")
  206. return
  207. else:
  208. logger.warning(f"Output directory '{output_dir}' is not empty. Existing files may be overwritten.")
  209. # Create the output directory if it doesn't exist
  210. output_dir.mkdir(parents=True, exist_ok=True)
  211. # Write rendered files to the output directory
  212. for file_path, content in rendered_files.items():
  213. full_path = output_dir / file_path
  214. full_path.parent.mkdir(parents=True, exist_ok=True)
  215. with open(full_path, 'w', encoding='utf-8') as f:
  216. f.write(content)
  217. console.print(f"[green]Generated file: {full_path}[/green]")
  218. logger.info(f"Template written to directory: {output_dir}")
  219. # If no output directory was specified, print the masked content to the console
  220. if not out:
  221. console.print("\n[bold]Rendered output (sensitive values masked):[/bold]")
  222. masked_files = template.mask_sensitive_values(rendered_files, template.variables)
  223. for file_path, content in masked_files.items():
  224. console.print(Panel(content, title=file_path, border_style="green"))
  225. except Exception as e:
  226. logger.error(f"Error rendering template '{id}': {e}")
  227. console.print(f"[red]Error generating template: {e}[/red]")
  228. # Stop execution without letting Typer/Click print the exception again.
  229. raise Exit(code=1)
  230. # --------------------------
  231. # SECTION: Config Commands
  232. # --------------------------
  233. def config_get(
  234. self,
  235. var_name: Optional[str] = Argument(None, help="Variable name to get (omit to show all defaults)"),
  236. ) -> None:
  237. """Get config default value(s) for this module.
  238. Examples:
  239. # Get all defaults for module
  240. cli compose config get
  241. # Get specific variable default
  242. cli compose config get service_name
  243. """
  244. from .config import ConfigManager
  245. config = ConfigManager()
  246. if var_name:
  247. # Get specific variable default
  248. value = config.get_default_value(self.name, var_name)
  249. if value is not None:
  250. console.print(f"[green]{var_name}[/green] = [yellow]{value}[/yellow]")
  251. else:
  252. console.print(f"[red]No default set for variable '{var_name}' in module '{self.name}'[/red]")
  253. else:
  254. # Show all defaults (flat list)
  255. defaults = config.get_defaults(self.name)
  256. if defaults:
  257. console.print(f"[bold]Config defaults for module '{self.name}':[/bold]\n")
  258. for var_name, var_value in defaults.items():
  259. console.print(f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]")
  260. else:
  261. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  262. def config_set(
  263. self,
  264. var_name: str = Argument(..., help="Variable name to set default for"),
  265. value: str = Argument(..., help="Default value"),
  266. ) -> None:
  267. """Set a default value for a variable in config.
  268. This only sets the DEFAULT VALUE, not the variable spec.
  269. The variable must be defined in the module or template spec.
  270. Examples:
  271. # Set default value
  272. cli compose config set service_name my-awesome-app
  273. # Set author for all compose templates
  274. cli compose config set author "Christian Lempa"
  275. """
  276. from .config import ConfigManager
  277. config = ConfigManager()
  278. # Set the default value
  279. config.set_default_value(self.name, var_name, value)
  280. console.print(f"[green]✓ Set default:[/green] [cyan]{var_name}[/cyan] = [yellow]{value}[/yellow]")
  281. console.print(f"\n[dim]This will be used as the default value when generating templates with this module.[/dim]")
  282. def config_remove(
  283. self,
  284. var_name: str = Argument(..., help="Variable name to remove"),
  285. ) -> None:
  286. """Remove a specific default variable value.
  287. Examples:
  288. # Remove a default value
  289. cli compose config remove service_name
  290. """
  291. from .config import ConfigManager
  292. config = ConfigManager()
  293. defaults = config.get_defaults(self.name)
  294. if not defaults:
  295. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  296. return
  297. if var_name in defaults:
  298. del defaults[var_name]
  299. config.set_defaults(self.name, defaults)
  300. console.print(f"[green]✓ Removed default for '{var_name}'[/green]")
  301. else:
  302. console.print(f"[red]No default found for variable '{var_name}'[/red]")
  303. def config_clear(
  304. self,
  305. var_name: Optional[str] = Argument(None, help="Variable name to clear (omit to clear all defaults)"),
  306. force: bool = Option(False, "--force", "-f", help="Skip confirmation prompt"),
  307. ) -> None:
  308. """Clear config default value(s) for this module.
  309. Examples:
  310. # Clear specific variable default
  311. cli compose config clear service_name
  312. # Clear all defaults for module
  313. cli compose config clear --force
  314. """
  315. from .config import ConfigManager
  316. config = ConfigManager()
  317. defaults = config.get_defaults(self.name)
  318. if not defaults:
  319. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  320. return
  321. if var_name:
  322. # Clear specific variable
  323. if var_name in defaults:
  324. del defaults[var_name]
  325. config.set_defaults(self.name, defaults)
  326. console.print(f"[green]✓ Cleared default for '{var_name}'[/green]")
  327. else:
  328. console.print(f"[red]No default found for variable '{var_name}'[/red]")
  329. else:
  330. # Clear all defaults
  331. if not force:
  332. console.print(f"[bold yellow]⚠️ Warning:[/bold yellow] This will clear ALL defaults for module '[cyan]{self.name}[/cyan]'")
  333. console.print()
  334. # Show what will be cleared
  335. for var_name, var_value in defaults.items():
  336. console.print(f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]")
  337. console.print()
  338. if not Confirm.ask(f"[bold red]Are you sure?[/bold red]", default=False):
  339. console.print("[green]Operation cancelled.[/green]")
  340. return
  341. config.clear_defaults(self.name)
  342. console.print(f"[green]✓ Cleared all defaults for module '{self.name}'[/green]")
  343. # !SECTION
  344. # ------------------------------
  345. # SECTION: CLI Registration
  346. # ------------------------------
  347. @classmethod
  348. def register_cli(cls, app: Typer) -> None:
  349. """Register module commands with the main app."""
  350. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  351. module_instance = cls()
  352. module_app = Typer(help=cls.description)
  353. module_app.command("list")(module_instance.list)
  354. module_app.command("search")(module_instance.search)
  355. module_app.command("show")(module_instance.show)
  356. module_app.command(
  357. "generate",
  358. context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
  359. )(module_instance.generate)
  360. # Add config commands (simplified - only manage default values)
  361. config_app = Typer(help="Manage default values for template variables")
  362. config_app.command("get", help="Get default value(s)")(module_instance.config_get)
  363. config_app.command("set", help="Set a default value")(module_instance.config_set)
  364. config_app.command("remove", help="Remove a specific default value")(module_instance.config_remove)
  365. config_app.command("clear", help="Clear default value(s)")(module_instance.config_clear)
  366. module_app.add_typer(config_app, name="config")
  367. app.add_typer(module_app, name=cls.name, help=cls.description)
  368. logger.info(f"Module '{cls.name}' CLI commands registered")
  369. # !SECTION
  370. # --------------------------
  371. # SECTION: Template Organization Methods
  372. # --------------------------
  373. def _filter_templates(self, templates: list[Template], filter_name: Optional[str], all_templates: bool) -> list[Template]:
  374. """Filter templates based on name and sub-template visibility."""
  375. filtered = []
  376. for template in templates:
  377. template_id = template.id
  378. is_sub_template = '.' in template_id
  379. # No filter - include based on all_templates flag
  380. if not all_templates and is_sub_template:
  381. continue
  382. filtered.append(template)
  383. return filtered
  384. def _search_templates(self, templates: list[Template], query: str, all_templates: bool) -> list[Template]:
  385. """Search templates by ID containing the query string."""
  386. filtered = []
  387. query_lower = query.lower()
  388. for template in templates:
  389. template_id = template.id
  390. is_sub_template = '.' in template_id
  391. # Skip sub-templates if not showing all
  392. if not all_templates and is_sub_template:
  393. continue
  394. # Check if query is contained in the template ID
  395. if query_lower in template_id.lower():
  396. filtered.append(template)
  397. return filtered
  398. def _group_templates(self, templates: list[Template]) -> list[dict]:
  399. """Group templates hierarchically for display."""
  400. grouped = []
  401. main_templates = {}
  402. sub_templates = []
  403. # Separate main templates and sub-templates
  404. for template in templates:
  405. if '.' in template.id:
  406. sub_templates.append(template)
  407. else:
  408. main_templates[template.id] = template
  409. grouped.append({
  410. 'template': template,
  411. 'indent': '',
  412. 'is_main': True
  413. })
  414. # Sort sub-templates by parent
  415. sub_templates.sort(key=lambda t: t.id)
  416. # Group sub-templates by parent for proper indentation
  417. sub_by_parent = {}
  418. for sub_template in sub_templates:
  419. parent_name = sub_template.id.split('.')[0]
  420. if parent_name not in sub_by_parent:
  421. sub_by_parent[parent_name] = []
  422. sub_by_parent[parent_name].append(sub_template)
  423. # Insert sub-templates after their parents with proper indentation
  424. for parent_name, parent_subs in sub_by_parent.items():
  425. # Find the parent in the grouped list
  426. insert_index = -1
  427. for i, item in enumerate(grouped):
  428. if item['template'].id == parent_name:
  429. insert_index = i + 1
  430. break
  431. # Add each sub-template with proper indentation
  432. for idx, sub_template in enumerate(parent_subs):
  433. is_last = (idx == len(parent_subs) - 1)
  434. sub_template_info = {
  435. 'template': sub_template,
  436. 'indent': '└─ ' if is_last else '├─ ',
  437. 'is_main': False
  438. }
  439. if insert_index >= 0:
  440. grouped.insert(insert_index, sub_template_info)
  441. insert_index += 1
  442. else:
  443. # Parent not found, add at end
  444. grouped.append(sub_template_info)
  445. return grouped
  446. # !SECTION
  447. # --------------------------
  448. # SECTION: Private Methods
  449. # --------------------------
  450. def _load_template_by_id(self, template_id: str) -> Template:
  451. result = self.libraries.find_by_id(self.name, template_id)
  452. if not result:
  453. logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
  454. raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
  455. template_dir, library_name = result
  456. try:
  457. return Template(template_dir, library_name=library_name)
  458. except (ValueError, FileNotFoundError) as exc:
  459. raise FileNotFoundError(f"Template '{template_id}' validation failed in module '{self.name}'") from exc
  460. except Exception as exc:
  461. logger.error(f"Failed to load template from {template_dir}: {exc}")
  462. raise FileNotFoundError(f"Template '{template_id}' could not be loaded in module '{self.name}'") from exc
  463. def _display_template_details(self, template: Template, template_id: str) -> None:
  464. """Display template information panel and variables table."""
  465. self.display.display_template_details(template, template_id)
  466. # !SECTION