module.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  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. def parse_var_inputs(var_options: list[str], extra_args: list[str]) -> dict[str, Any]:
  17. """Parse variable inputs from --var options and extra args.
  18. Supports formats:
  19. --var KEY=VALUE
  20. --var KEY VALUE
  21. Args:
  22. var_options: List of variable options from CLI
  23. extra_args: Additional arguments that may contain values
  24. Returns:
  25. Dictionary of parsed variables
  26. """
  27. variables = {}
  28. # Parse --var KEY=VALUE format
  29. for var_option in var_options:
  30. if '=' in var_option:
  31. key, value = var_option.split('=', 1)
  32. variables[key] = value
  33. else:
  34. # --var KEY VALUE format - value should be in extra_args
  35. if extra_args:
  36. variables[var_option] = extra_args.pop(0)
  37. else:
  38. logger.warning(f"No value provided for variable '{var_option}'")
  39. return variables
  40. class Module(ABC):
  41. """Streamlined base module that auto-detects variables from templates."""
  42. def __init__(self) -> None:
  43. if not all([self.name, self.description]):
  44. raise ValueError(
  45. f"Module {self.__class__.__name__} must define name and description"
  46. )
  47. logger.info(f"Initializing module '{self.name}'")
  48. logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
  49. self.libraries = LibraryManager()
  50. self.display = DisplayManager()
  51. def list(self) -> list[Template]:
  52. """List all templates."""
  53. logger.debug(f"Listing templates for module '{self.name}'")
  54. templates = []
  55. entries = self.libraries.find(self.name, sort_results=True)
  56. for template_dir, library_name in entries:
  57. try:
  58. template = Template(template_dir, library_name=library_name)
  59. templates.append(template)
  60. except Exception as exc:
  61. logger.error(f"Failed to load template from {template_dir}: {exc}")
  62. continue
  63. filtered_templates = templates
  64. if filtered_templates:
  65. self.display.display_templates_table(
  66. filtered_templates,
  67. self.name,
  68. f"{self.name.capitalize()} templates"
  69. )
  70. else:
  71. logger.info(f"No templates found for module '{self.name}'")
  72. return filtered_templates
  73. def search(
  74. self,
  75. query: str = Argument(..., help="Search string to filter templates by ID")
  76. ) -> list[Template]:
  77. """Search for templates by ID containing the search string."""
  78. logger.debug(f"Searching templates for module '{self.name}' with query='{query}'")
  79. templates = []
  80. entries = self.libraries.find(self.name, sort_results=True)
  81. for template_dir, library_name in entries:
  82. try:
  83. template = Template(template_dir, library_name=library_name)
  84. templates.append(template)
  85. except Exception as exc:
  86. logger.error(f"Failed to load template from {template_dir}: {exc}")
  87. continue
  88. # Apply search filtering
  89. filtered_templates = [t for t in templates if query.lower() in t.id.lower()]
  90. if filtered_templates:
  91. logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{self.name}'")
  92. self.display.display_templates_table(
  93. filtered_templates,
  94. self.name,
  95. f"{self.name.capitalize()} templates matching '{query}'"
  96. )
  97. else:
  98. logger.info(f"No templates found matching '{query}' for module '{self.name}'")
  99. console.print(f"[yellow]No templates found matching '{query}' for module '{self.name}'[/yellow]")
  100. return filtered_templates
  101. def show(
  102. self,
  103. id: str,
  104. show_content: bool = False,
  105. ) -> None:
  106. """Show template details."""
  107. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  108. template = self._load_template_by_id(id)
  109. if not template:
  110. logger.warning(f"Template '{id}' not found in module '{self.name}'")
  111. console.print(f"[red]Template '{id}' not found in module '{self.name}'[/red]")
  112. return
  113. # Apply config defaults (same as in generate)
  114. # This ensures the display shows the actual defaults that will be used
  115. if template.variables:
  116. from .config import ConfigManager
  117. config = ConfigManager()
  118. config_defaults = config.get_defaults(self.name)
  119. if config_defaults:
  120. logger.debug(f"Loading config defaults for module '{self.name}'")
  121. # Apply config defaults (this respects the variable types and validation)
  122. successful = template.variables.apply_defaults(config_defaults, "config")
  123. if successful:
  124. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  125. # Re-sort sections after applying config (toggle values may have changed)
  126. template.variables.sort_sections()
  127. self._display_template_details(template, id)
  128. def generate(
  129. self,
  130. id: str = Argument(..., help="Template ID"),
  131. directory: Optional[str] = Argument(None, help="Output directory (defaults to template ID)"),
  132. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  133. var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  134. dry_run: bool = Option(False, "--dry-run", help="Preview template generation without writing files"),
  135. ctx: Context = None,
  136. ) -> None:
  137. """Generate from template.
  138. Variable precedence chain (lowest to highest):
  139. 1. Module spec (defined in cli/modules/*.py)
  140. 2. Template spec (from template.yaml)
  141. 3. Config defaults (from ~/.config/boilerplates/config.yaml)
  142. 4. CLI overrides (--var flags)
  143. Examples:
  144. # Generate to directory named after template
  145. cli compose generate traefik
  146. # Generate to custom directory
  147. cli compose generate traefik my-proxy
  148. # Generate with variables
  149. cli compose generate traefik --var traefik_enabled=false
  150. # Preview without writing files (dry run)
  151. cli compose generate traefik --dry-run
  152. """
  153. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  154. template = self._load_template_by_id(id)
  155. # Apply config defaults (precedence: config > template > module)
  156. # Config only sets VALUES, not the spec structure
  157. if template.variables:
  158. from .config import ConfigManager
  159. config = ConfigManager()
  160. config_defaults = config.get_defaults(self.name)
  161. if config_defaults:
  162. logger.info(f"Loading config defaults for module '{self.name}'")
  163. # Apply config defaults (this respects the variable types and validation)
  164. successful = template.variables.apply_defaults(config_defaults, "config")
  165. if successful:
  166. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  167. # Apply CLI overrides (highest precedence)
  168. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  169. cli_overrides = parse_var_inputs(var or [], extra_args)
  170. if cli_overrides:
  171. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  172. if template.variables:
  173. successful_overrides = template.variables.apply_defaults(cli_overrides, "cli")
  174. if successful_overrides:
  175. logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
  176. # Re-sort sections after all overrides (toggle values may have changed)
  177. if template.variables:
  178. template.variables.sort_sections()
  179. self._display_template_details(template, id)
  180. console.print()
  181. variable_values = {}
  182. if interactive and template.variables:
  183. prompt_handler = PromptHandler()
  184. collected_values = prompt_handler.collect_variables(template.variables)
  185. if collected_values:
  186. variable_values.update(collected_values)
  187. logger.info(f"Collected {len(collected_values)} variable values from user input")
  188. if template.variables:
  189. # Use get_satisfied_values() to exclude variables from sections with unsatisfied dependencies
  190. variable_values.update(template.variables.get_satisfied_values())
  191. try:
  192. # Validate all variables before rendering
  193. if template.variables:
  194. template.variables.validate_all()
  195. rendered_files, variable_values = template.render(template.variables)
  196. # Safety check for render result
  197. if not rendered_files:
  198. console.print("[red]Error: Template rendering returned no files[/red]")
  199. raise Exit(code=1)
  200. logger.info(f"Successfully rendered template '{id}'")
  201. # Determine output directory (default to template ID)
  202. output_dir = Path(directory) if directory else Path(id)
  203. # Check if directory exists and is not empty
  204. dir_exists = output_dir.exists()
  205. dir_not_empty = dir_exists and any(output_dir.iterdir())
  206. # Check which files already exist
  207. existing_files = []
  208. if dir_exists:
  209. for file_path in rendered_files.keys():
  210. full_path = output_dir / file_path
  211. if full_path.exists():
  212. existing_files.append(full_path)
  213. # Warn if directory is not empty (both interactive and non-interactive)
  214. if dir_not_empty:
  215. if interactive:
  216. console.print(f"\n[yellow]⚠ Warning: Directory '{output_dir}' is not empty.[/yellow]")
  217. if existing_files:
  218. console.print(f"[yellow] {len(existing_files)} file(s) will be overwritten.[/yellow]")
  219. if not Confirm.ask(f"Continue and potentially overwrite files in '{output_dir}'?", default=False):
  220. console.print("[yellow]Generation cancelled.[/yellow]")
  221. return
  222. else:
  223. # Non-interactive mode: show warning but continue
  224. logger.warning(f"Directory '{output_dir}' is not empty")
  225. if existing_files:
  226. logger.warning(f"{len(existing_files)} file(s) will be overwritten")
  227. # Display file generation confirmation in interactive mode
  228. if interactive:
  229. self.display.display_file_generation_confirmation(
  230. output_dir,
  231. rendered_files,
  232. existing_files if existing_files else None
  233. )
  234. # Final confirmation (only if we didn't already ask about overwriting)
  235. if not dir_not_empty and not dry_run:
  236. if not Confirm.ask("Generate these files?", default=True):
  237. console.print("[yellow]Generation cancelled.[/yellow]")
  238. return
  239. # Skip file writing in dry-run mode
  240. if dry_run:
  241. console.print(f"\n[yellow]✓ Dry run complete - no files were written[/yellow]")
  242. console.print(f"[dim]Files would have been generated in '{output_dir}'[/dim]")
  243. logger.info(f"Dry run completed for template '{id}'")
  244. else:
  245. # Create the output directory if it doesn't exist
  246. output_dir.mkdir(parents=True, exist_ok=True)
  247. # Write rendered files to the output directory
  248. for file_path, content in rendered_files.items():
  249. full_path = output_dir / file_path
  250. full_path.parent.mkdir(parents=True, exist_ok=True)
  251. with open(full_path, 'w', encoding='utf-8') as f:
  252. f.write(content)
  253. console.print(f"[green]Generated file: {file_path}[/green]")
  254. console.print(f"\n[green]✓ Template generated successfully in '{output_dir}'[/green]")
  255. logger.info(f"Template written to directory: {output_dir}")
  256. # Display next steps if provided in template metadata
  257. if template.metadata.next_steps:
  258. self.display.display_next_steps(template.metadata.next_steps, variable_values)
  259. except Exception as e:
  260. logger.error(f"Error rendering template '{id}': {e}")
  261. console.print(f"[red]Error generating template: {e}[/red]")
  262. # Stop execution without letting Typer/Click print the exception again.
  263. raise Exit(code=1)
  264. def config_get(
  265. self,
  266. var_name: Optional[str] = Argument(None, help="Variable name to get (omit to show all defaults)"),
  267. ) -> None:
  268. """Get default value(s) for this module.
  269. Examples:
  270. # Get all defaults for module
  271. cli compose defaults get
  272. # Get specific variable default
  273. cli compose defaults get service_name
  274. """
  275. from .config import ConfigManager
  276. config = ConfigManager()
  277. if var_name:
  278. # Get specific variable default
  279. value = config.get_default_value(self.name, var_name)
  280. if value is not None:
  281. console.print(f"[green]{var_name}[/green] = [yellow]{value}[/yellow]")
  282. else:
  283. console.print(f"[red]No default set for variable '{var_name}' in module '{self.name}'[/red]")
  284. else:
  285. # Show all defaults (flat list)
  286. defaults = config.get_defaults(self.name)
  287. if defaults:
  288. console.print(f"[bold]Config defaults for module '{self.name}':[/bold]\n")
  289. for var_name, var_value in defaults.items():
  290. console.print(f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]")
  291. else:
  292. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  293. def config_set(
  294. self,
  295. var_name: str = Argument(..., help="Variable name to set default for"),
  296. value: str = Argument(..., help="Default value"),
  297. ) -> None:
  298. """Set a default value for a variable.
  299. This only sets the DEFAULT VALUE, not the variable spec.
  300. The variable must be defined in the module or template spec.
  301. Examples:
  302. # Set default value
  303. cli compose defaults set service_name my-awesome-app
  304. # Set author for all compose templates
  305. cli compose defaults set author "Christian Lempa"
  306. """
  307. from .config import ConfigManager
  308. config = ConfigManager()
  309. # Set the default value
  310. config.set_default_value(self.name, var_name, value)
  311. console.print(f"[green] Set default:[/green] [cyan]{var_name}[/cyan] = [yellow]{value}[/yellow]")
  312. console.print(f"\n[dim]This will be used as the default value when generating templates with this module.[/dim]")
  313. def config_remove(
  314. self,
  315. var_name: str = Argument(..., help="Variable name to remove"),
  316. ) -> None:
  317. """Remove a specific default variable value.
  318. Examples:
  319. # Remove a default value
  320. cli compose defaults rm service_name
  321. """
  322. from .config import ConfigManager
  323. config = ConfigManager()
  324. defaults = config.get_defaults(self.name)
  325. if not defaults:
  326. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  327. return
  328. if var_name in defaults:
  329. del defaults[var_name]
  330. config.set_defaults(self.name, defaults)
  331. console.print(f"[green] Removed default for '{var_name}'[/green]")
  332. else:
  333. console.print(f"[red]No default found for variable '{var_name}'[/red]")
  334. def config_clear(
  335. self,
  336. var_name: Optional[str] = Argument(None, help="Variable name to clear (omit to clear all defaults)"),
  337. force: bool = Option(False, "--force", "-f", help="Skip confirmation prompt"),
  338. ) -> None:
  339. """Clear default value(s) for this module.
  340. Examples:
  341. # Clear specific variable default
  342. cli compose defaults clear service_name
  343. # Clear all defaults for module
  344. cli compose defaults clear --force
  345. """
  346. from .config import ConfigManager
  347. config = ConfigManager()
  348. defaults = config.get_defaults(self.name)
  349. if not defaults:
  350. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  351. return
  352. if var_name:
  353. # Clear specific variable
  354. if var_name in defaults:
  355. del defaults[var_name]
  356. config.set_defaults(self.name, defaults)
  357. console.print(f"[green] Cleared default for '{var_name}'[/green]")
  358. else:
  359. console.print(f"[red]No default found for variable '{var_name}'[/red]")
  360. else:
  361. # Clear all defaults
  362. if not force:
  363. console.print(f"[bold yellow] Warning:[/bold yellow] This will clear ALL defaults for module '[cyan]{self.name}[/cyan]'")
  364. console.print()
  365. # Show what will be cleared
  366. for var_name, var_value in defaults.items():
  367. console.print(f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]")
  368. console.print()
  369. if not Confirm.ask(f"[bold red]Are you sure?[/bold red]", default=False):
  370. console.print("[green]Operation cancelled.[/green]")
  371. return
  372. config.clear_defaults(self.name)
  373. console.print(f"[green] Cleared all defaults for module '{self.name}'[/green]")
  374. def config_list(self) -> None:
  375. """Display the defaults for this specific module in YAML format.
  376. Examples:
  377. # Show the defaults for the current module
  378. cli compose defaults list
  379. """
  380. from .config import ConfigManager
  381. import yaml
  382. config = ConfigManager()
  383. # Get only the defaults for this module
  384. defaults = config.get_defaults(self.name)
  385. if not defaults:
  386. console.print(f"[yellow]No configuration found for module '{self.name}'[/yellow]")
  387. console.print(f"\n[dim]Config file location: {config.get_config_path()}[/dim]")
  388. return
  389. # Create a minimal config structure with only this module's defaults
  390. module_config = {
  391. "defaults": {
  392. self.name: defaults
  393. }
  394. }
  395. # Convert config to YAML string
  396. yaml_output = yaml.dump(module_config, default_flow_style=False, sort_keys=False)
  397. console.print(f"[bold]Configuration for module:[/bold] [cyan]{self.name}[/cyan]")
  398. console.print(f"[dim]Config file: {config.get_config_path()}[/dim]\n")
  399. console.print(Panel(yaml_output, title=f"{self.name.capitalize()} Config", border_style="blue"))
  400. def validate(
  401. self,
  402. template_id: str = Argument(None, help="Template ID to validate (if omitted, validates all templates)"),
  403. verbose: bool = Option(False, "--verbose", "-v", help="Show detailed validation information")
  404. ) -> None:
  405. """Validate templates for Jinja2 syntax errors and undefined variables.
  406. Examples:
  407. # Validate all templates in this module
  408. cli compose validate
  409. # Validate a specific template
  410. cli compose validate gitlab
  411. # Validate with verbose output
  412. cli compose validate --verbose
  413. """
  414. from rich.table import Table
  415. if template_id:
  416. # Validate a specific template
  417. try:
  418. template = self._load_template_by_id(template_id)
  419. console.print(f"[bold]Validating template:[/bold] [cyan]{template_id}[/cyan]\n")
  420. try:
  421. # Trigger validation by accessing used_variables
  422. _ = template.used_variables
  423. # Trigger variable definition validation by accessing variables
  424. _ = template.variables
  425. console.print(f"[green] Template '{template_id}' is valid[/green]")
  426. if verbose:
  427. console.print(f"\n[dim]Template path: {template.template_dir}[/dim]")
  428. console.print(f"[dim]Found {len(template.used_variables)} variables[/dim]")
  429. except ValueError as e:
  430. console.print(f"[red] Validation failed for '{template_id}':[/red]")
  431. console.print(f"\n{e}")
  432. raise Exit(code=1)
  433. except Exception as e:
  434. console.print(f"[red]Error loading template '{template_id}': {e}[/red]")
  435. raise Exit(code=1)
  436. else:
  437. # Validate all templates
  438. console.print(f"[bold]Validating all {self.name} templates...[/bold]\n")
  439. entries = self.libraries.find(self.name, sort_results=True)
  440. total = len(entries)
  441. valid_count = 0
  442. invalid_count = 0
  443. errors = []
  444. for template_dir, library_name in entries:
  445. template_id = template_dir.name
  446. try:
  447. template = Template(template_dir, library_name=library_name)
  448. # Trigger validation
  449. _ = template.used_variables
  450. _ = template.variables
  451. valid_count += 1
  452. if verbose:
  453. console.print(f"[green][/green] {template_id}")
  454. except ValueError as e:
  455. invalid_count += 1
  456. errors.append((template_id, str(e)))
  457. if verbose:
  458. console.print(f"[red][/red] {template_id}")
  459. except Exception as e:
  460. invalid_count += 1
  461. errors.append((template_id, f"Load error: {e}"))
  462. if verbose:
  463. console.print(f"[yellow]?[/yellow] {template_id}")
  464. # Summary
  465. console.print(f"\n[bold]Validation Summary:[/bold]")
  466. summary_table = Table(show_header=False, box=None, padding=(0, 2))
  467. summary_table.add_column(style="bold")
  468. summary_table.add_column()
  469. summary_table.add_row("Total templates:", str(total))
  470. summary_table.add_row("[green]Valid:[/green]", str(valid_count))
  471. summary_table.add_row("[red]Invalid:[/red]", str(invalid_count))
  472. console.print(summary_table)
  473. # Show errors if any
  474. if errors:
  475. console.print(f"\n[bold red]Validation Errors:[/bold red]")
  476. for template_id, error_msg in errors:
  477. console.print(f"\n[yellow]Template:[/yellow] [cyan]{template_id}[/cyan]")
  478. console.print(f"[dim]{error_msg}[/dim]")
  479. raise Exit(code=1)
  480. else:
  481. console.print(f"\n[green] All templates are valid![/green]")
  482. @classmethod
  483. def register_cli(cls, app: Typer) -> None:
  484. """Register module commands with the main app."""
  485. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  486. module_instance = cls()
  487. module_app = Typer(help=cls.description)
  488. module_app.command("list")(module_instance.list)
  489. module_app.command("search")(module_instance.search)
  490. module_app.command("show")(module_instance.show)
  491. module_app.command("validate")(module_instance.validate)
  492. module_app.command(
  493. "generate",
  494. context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
  495. )(module_instance.generate)
  496. # Add defaults commands (simplified - only manage default values)
  497. defaults_app = Typer(help="Manage default values for template variables")
  498. defaults_app.command("get", help="Get default value(s)")(module_instance.config_get)
  499. defaults_app.command("set", help="Set a default value")(module_instance.config_set)
  500. defaults_app.command("rm", help="Remove a specific default value")(module_instance.config_remove)
  501. defaults_app.command("clear", help="Clear default value(s)")(module_instance.config_clear)
  502. defaults_app.command("list", help="Display the config for this module in YAML format")(module_instance.config_list)
  503. module_app.add_typer(defaults_app, name="defaults")
  504. app.add_typer(module_app, name=cls.name, help=cls.description)
  505. logger.info(f"Module '{cls.name}' CLI commands registered")
  506. def _load_template_by_id(self, template_id: str) -> Template:
  507. result = self.libraries.find_by_id(self.name, template_id)
  508. if not result:
  509. logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
  510. raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
  511. template_dir, library_name = result
  512. try:
  513. return Template(template_dir, library_name=library_name)
  514. except (ValueError, FileNotFoundError) as exc:
  515. raise FileNotFoundError(f"Template '{template_id}' validation failed in module '{self.name}'") from exc
  516. except Exception as exc:
  517. logger.error(f"Failed to load template from {template_dir}: {exc}")
  518. raise FileNotFoundError(f"Template '{template_id}' could not be loaded in module '{self.name}'") from exc
  519. def _display_template_details(self, template: Template, template_id: str) -> None:
  520. """Display template information panel and variables table."""
  521. self.display.display_template_details(template, template_id)