module.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  1. from __future__ import annotations
  2. import logging
  3. import sys
  4. from abc import ABC
  5. from pathlib import Path
  6. from typing import Any, Optional, List, Dict, Tuple
  7. from rich.console import Console
  8. from rich.panel import Panel
  9. from rich.prompt import Confirm
  10. from typer import Argument, Context, Option, Typer, Exit
  11. from .display import DisplayManager, IconManager
  12. from .library import LibraryManager
  13. from .prompt import PromptHandler
  14. from .template import Template
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. console_err = Console(stderr=True)
  18. def parse_var_inputs(var_options: List[str], extra_args: List[str]) -> Dict[str, Any]:
  19. """Parse variable inputs from --var options and extra args.
  20. Supports formats:
  21. --var KEY=VALUE
  22. --var KEY VALUE
  23. Args:
  24. var_options: List of variable options from CLI
  25. extra_args: Additional arguments that may contain values
  26. Returns:
  27. Dictionary of parsed variables
  28. """
  29. variables = {}
  30. # Parse --var KEY=VALUE format
  31. for var_option in var_options:
  32. if '=' in var_option:
  33. key, value = var_option.split('=', 1)
  34. variables[key] = value
  35. else:
  36. # --var KEY VALUE format - value should be in extra_args
  37. if extra_args:
  38. variables[var_option] = extra_args.pop(0)
  39. else:
  40. logger.warning(f"No value provided for variable '{var_option}'")
  41. return variables
  42. class Module(ABC):
  43. """Streamlined base module that auto-detects variables from templates."""
  44. def __init__(self) -> None:
  45. if not all([self.name, self.description]):
  46. raise ValueError(
  47. f"Module {self.__class__.__name__} must define name and description"
  48. )
  49. logger.info(f"Initializing module '{self.name}'")
  50. logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
  51. self.libraries = LibraryManager()
  52. self.display = DisplayManager()
  53. def list(
  54. self,
  55. raw: bool = Option(False, "--raw", help="Output raw list format instead of rich table")
  56. ) -> list[Template]:
  57. """List all templates."""
  58. logger.debug(f"Listing templates for module '{self.name}'")
  59. templates = []
  60. entries = self.libraries.find(self.name, sort_results=True)
  61. for template_dir, library_name in entries:
  62. try:
  63. template = Template(template_dir, library_name=library_name)
  64. templates.append(template)
  65. except Exception as exc:
  66. logger.error(f"Failed to load template from {template_dir}: {exc}")
  67. continue
  68. filtered_templates = templates
  69. if filtered_templates:
  70. if raw:
  71. # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
  72. # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
  73. for template in filtered_templates:
  74. name = template.metadata.name or "Unnamed Template"
  75. tags_list = template.metadata.tags or []
  76. tags = ",".join(tags_list) if tags_list else "-"
  77. version = str(template.metadata.version) if template.metadata.version else "-"
  78. library = template.metadata.library or "-"
  79. print(f"{template.id}\t{name}\t{tags}\t{version}\t{library}")
  80. else:
  81. # Output rich table format
  82. self.display.display_templates_table(
  83. filtered_templates,
  84. self.name,
  85. f"{self.name.capitalize()} templates"
  86. )
  87. else:
  88. logger.info(f"No templates found for module '{self.name}'")
  89. return filtered_templates
  90. def search(
  91. self,
  92. query: str = Argument(..., help="Search string to filter templates by ID")
  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}'")
  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 = [t for t in templates if query.lower() in t.id.lower()]
  107. if filtered_templates:
  108. logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{self.name}'")
  109. self.display.display_templates_table(
  110. filtered_templates,
  111. self.name,
  112. f"{self.name.capitalize()} templates matching '{query}'"
  113. )
  114. else:
  115. logger.info(f"No templates found matching '{query}' for module '{self.name}'")
  116. console.print(f"[yellow]No templates found matching '{query}' for module '{self.name}'[/yellow]")
  117. return filtered_templates
  118. def show(
  119. self,
  120. id: str,
  121. ) -> None:
  122. """Show template details."""
  123. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  124. template = self._load_template_by_id(id)
  125. if not template:
  126. self.display.display_error(f"Template '{id}' not found", context=f"module '{self.name}'")
  127. return
  128. # Apply config defaults (same as in generate)
  129. # This ensures the display shows the actual defaults that will be used
  130. if template.variables:
  131. from .config import ConfigManager
  132. config = ConfigManager()
  133. config_defaults = config.get_defaults(self.name)
  134. if config_defaults:
  135. logger.debug(f"Loading config defaults for module '{self.name}'")
  136. # Apply config defaults (this respects the variable types and validation)
  137. successful = template.variables.apply_defaults(config_defaults, "config")
  138. if successful:
  139. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  140. # Re-sort sections after applying config (toggle values may have changed)
  141. template.variables.sort_sections()
  142. self._display_template_details(template, id)
  143. def _apply_variable_defaults(self, template: Template) -> None:
  144. """Apply config defaults and CLI overrides to template variables.
  145. Args:
  146. template: Template instance with variables to configure
  147. """
  148. if not template.variables:
  149. return
  150. from .config import ConfigManager
  151. config = ConfigManager()
  152. config_defaults = config.get_defaults(self.name)
  153. if config_defaults:
  154. logger.info(f"Loading config defaults for module '{self.name}'")
  155. successful = template.variables.apply_defaults(config_defaults, "config")
  156. if successful:
  157. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  158. def _apply_cli_overrides(self, template: Template, var: Optional[List[str]], ctx: Context) -> None:
  159. """Apply CLI variable overrides to template.
  160. Args:
  161. template: Template instance to apply overrides to
  162. var: List of variable override strings from --var flags
  163. ctx: Typer context containing extra args
  164. """
  165. if not template.variables:
  166. return
  167. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  168. cli_overrides = parse_var_inputs(var or [], extra_args)
  169. if cli_overrides:
  170. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  171. successful_overrides = template.variables.apply_defaults(cli_overrides, "cli")
  172. if successful_overrides:
  173. logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
  174. def _collect_variable_values(self, template: Template, interactive: bool) -> Dict[str, Any]:
  175. """Collect variable values from user prompts and template defaults.
  176. Args:
  177. template: Template instance with variables
  178. interactive: Whether to prompt user for values interactively
  179. Returns:
  180. Dictionary of variable names to values
  181. """
  182. variable_values = {}
  183. # Collect values interactively if enabled
  184. if interactive and template.variables:
  185. prompt_handler = PromptHandler()
  186. collected_values = prompt_handler.collect_variables(template.variables)
  187. if collected_values:
  188. variable_values.update(collected_values)
  189. logger.info(f"Collected {len(collected_values)} variable values from user input")
  190. # Add satisfied variable values (respects dependencies and toggles)
  191. if template.variables:
  192. variable_values.update(template.variables.get_satisfied_values())
  193. return variable_values
  194. def _check_output_directory(self, output_dir: Path, rendered_files: Dict[str, str],
  195. interactive: bool) -> Optional[List[Path]]:
  196. """Check output directory for conflicts and get user confirmation if needed.
  197. Args:
  198. output_dir: Directory where files will be written
  199. rendered_files: Dictionary of file paths to rendered content
  200. interactive: Whether to prompt user for confirmation
  201. Returns:
  202. List of existing files that will be overwritten, or None to cancel
  203. """
  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
  214. if dir_not_empty:
  215. if interactive:
  216. console.print(f"\n[yellow]{IconManager.get_status_icon('warning')} 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 None
  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. return existing_files
  228. def _get_generation_confirmation(self, output_dir: Path, rendered_files: Dict[str, str],
  229. existing_files: Optional[List[Path]], dir_not_empty: bool,
  230. dry_run: bool, interactive: bool) -> bool:
  231. """Display file generation confirmation and get user approval.
  232. Args:
  233. output_dir: Output directory path
  234. rendered_files: Dictionary of file paths to content
  235. existing_files: List of existing files that will be overwritten
  236. dir_not_empty: Whether output directory already contains files
  237. dry_run: Whether this is a dry run
  238. interactive: Whether to prompt for confirmation
  239. Returns:
  240. True if user confirms generation, False to cancel
  241. """
  242. if not interactive:
  243. return True
  244. self.display.display_file_generation_confirmation(
  245. output_dir,
  246. rendered_files,
  247. existing_files if existing_files else None
  248. )
  249. # Final confirmation (only if we didn't already ask about overwriting)
  250. if not dir_not_empty and not dry_run:
  251. if not Confirm.ask("Generate these files?", default=True):
  252. console.print("[yellow]Generation cancelled.[/yellow]")
  253. return False
  254. return True
  255. def _execute_dry_run(self, id: str, output_dir: Path, rendered_files: Dict[str, str], show_files: bool) -> None:
  256. """Execute dry run mode with comprehensive simulation.
  257. Simulates all filesystem operations that would occur during actual generation,
  258. including directory creation, file writing, and permission checks.
  259. Args:
  260. id: Template ID
  261. output_dir: Directory where files would be written
  262. rendered_files: Dictionary of file paths to rendered content
  263. show_files: Whether to display file contents
  264. """
  265. import os
  266. from rich.table import Table
  267. console.print()
  268. console.print("[bold cyan]Dry Run Mode - Simulating File Generation[/bold cyan]")
  269. console.print()
  270. # Simulate directory creation
  271. console.print(f"[bold]{IconManager.folder()} Directory Operations:[/bold]")
  272. # Check if output directory exists
  273. if output_dir.exists():
  274. console.print(f" [green]{IconManager.get_status_icon('success')}[/green] Output directory exists: [cyan]{output_dir}[/cyan]")
  275. # Check if we have write permissions
  276. if os.access(output_dir, os.W_OK):
  277. console.print(f" [green]{IconManager.get_status_icon('success')}[/green] Write permission verified")
  278. else:
  279. console.print(f" [yellow]{IconManager.get_status_icon('warning')}[/yellow] Write permission may be denied")
  280. else:
  281. console.print(f" [dim]{IconManager.arrow_right()}[/dim] Would create output directory: [cyan]{output_dir}[/cyan]")
  282. # Check if parent directory exists and is writable
  283. parent = output_dir.parent
  284. if parent.exists() and os.access(parent, os.W_OK):
  285. console.print(f" [green]{IconManager.get_status_icon('success')}[/green] Parent directory writable")
  286. else:
  287. console.print(f" [yellow]{IconManager.get_status_icon('warning')}[/yellow] Parent directory may not be writable")
  288. # Collect unique subdirectories that would be created
  289. subdirs = set()
  290. for file_path in rendered_files.keys():
  291. parts = Path(file_path).parts
  292. for i in range(1, len(parts)):
  293. subdirs.add(Path(*parts[:i]))
  294. if subdirs:
  295. console.print(f" [dim]{IconManager.arrow_right()}[/dim] Would create {len(subdirs)} subdirectory(ies)")
  296. for subdir in sorted(subdirs):
  297. console.print(f" [dim]{IconManager.folder()}[/dim] {subdir}/")
  298. console.print()
  299. # Display file operations in a table
  300. console.print(f"[bold]{IconManager.get_file_icon('file.txt')} File Operations:[/bold]")
  301. table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
  302. table.add_column("File", style="white", no_wrap=False)
  303. table.add_column("Size", justify="right", style="dim")
  304. table.add_column("Status", style="yellow")
  305. total_size = 0
  306. new_files = 0
  307. overwrite_files = 0
  308. for file_path, content in sorted(rendered_files.items()):
  309. full_path = output_dir / file_path
  310. file_size = len(content.encode('utf-8'))
  311. total_size += file_size
  312. # Determine status
  313. if full_path.exists():
  314. status = "Overwrite"
  315. overwrite_files += 1
  316. else:
  317. status = "Create"
  318. new_files += 1
  319. # Format size
  320. if file_size < 1024:
  321. size_str = f"{file_size}B"
  322. elif file_size < 1024 * 1024:
  323. size_str = f"{file_size / 1024:.1f}KB"
  324. else:
  325. size_str = f"{file_size / (1024 * 1024):.1f}MB"
  326. table.add_row(str(file_path), size_str, status)
  327. console.print(table)
  328. console.print()
  329. # Summary statistics
  330. console.print(f"[bold]{IconManager.get_status_icon('info')} Summary:[/bold]")
  331. console.print(f" Total files: {len(rendered_files)}")
  332. console.print(f" New files: {new_files}")
  333. console.print(f" Files to overwrite: {overwrite_files}")
  334. if total_size < 1024:
  335. size_str = f"{total_size}B"
  336. elif total_size < 1024 * 1024:
  337. size_str = f"{total_size / 1024:.1f}KB"
  338. else:
  339. size_str = f"{total_size / (1024 * 1024):.1f}MB"
  340. console.print(f" Total size: {size_str}")
  341. console.print()
  342. # Show file contents if requested
  343. if show_files:
  344. console.print("[bold cyan]Generated File Contents:[/bold cyan]")
  345. console.print()
  346. for file_path, content in sorted(rendered_files.items()):
  347. console.print(f"[cyan]File:[/cyan] {file_path}")
  348. print(f"{'─'*80}")
  349. print(content)
  350. print() # Add blank line after content
  351. console.print()
  352. console.print(f"[yellow]{IconManager.get_status_icon('success')} Dry run complete - no files were written[/yellow]")
  353. console.print(f"[dim]Files would have been generated in '{output_dir}'[/dim]")
  354. logger.info(f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes")
  355. def _write_generated_files(self, output_dir: Path, rendered_files: Dict[str, str]) -> None:
  356. """Write rendered files to the output directory.
  357. Args:
  358. output_dir: Directory to write files to
  359. rendered_files: Dictionary of file paths to rendered content
  360. """
  361. output_dir.mkdir(parents=True, exist_ok=True)
  362. for file_path, content in rendered_files.items():
  363. full_path = output_dir / file_path
  364. full_path.parent.mkdir(parents=True, exist_ok=True)
  365. with open(full_path, 'w', encoding='utf-8') as f:
  366. f.write(content)
  367. console.print(f"[green]Generated file: {file_path}[/green]")
  368. console.print(f"\n[green]{IconManager.get_status_icon('success')} Template generated successfully in '{output_dir}'[/green]")
  369. logger.info(f"Template written to directory: {output_dir}")
  370. def generate(
  371. self,
  372. id: str = Argument(..., help="Template ID"),
  373. directory: Optional[str] = Argument(None, help="Output directory (defaults to template ID)"),
  374. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  375. var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE"),
  376. dry_run: bool = Option(False, "--dry-run", help="Preview template generation without writing files"),
  377. show_files: bool = Option(False, "--show-files", help="Display generated file contents in plain text (use with --dry-run)"),
  378. ctx: Context = None,
  379. ) -> None:
  380. """Generate from template.
  381. Variable precedence chain (lowest to highest):
  382. 1. Module spec (defined in cli/modules/*.py)
  383. 2. Template spec (from template.yaml)
  384. 3. Config defaults (from ~/.config/boilerplates/config.yaml)
  385. 4. CLI overrides (--var flags)
  386. Examples:
  387. # Generate to directory named after template
  388. cli compose generate traefik
  389. # Generate to custom directory
  390. cli compose generate traefik my-proxy
  391. # Generate with variables
  392. cli compose generate traefik --var traefik_enabled=false
  393. # Preview without writing files (dry run)
  394. cli compose generate traefik --dry-run
  395. # Preview and show generated file contents
  396. cli compose generate traefik --dry-run --show-files
  397. """
  398. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  399. template = self._load_template_by_id(id)
  400. # Apply defaults and overrides
  401. self._apply_variable_defaults(template)
  402. self._apply_cli_overrides(template, var, ctx)
  403. # Re-sort sections after all overrides (toggle values may have changed)
  404. if template.variables:
  405. template.variables.sort_sections()
  406. self._display_template_details(template, id)
  407. console.print()
  408. # Collect variable values
  409. variable_values = self._collect_variable_values(template, interactive)
  410. try:
  411. # Validate and render template
  412. if template.variables:
  413. template.variables.validate_all()
  414. rendered_files, variable_values = template.render(template.variables)
  415. if not rendered_files:
  416. self.display.display_error("Template rendering returned no files", context="template generation")
  417. raise Exit(code=1)
  418. logger.info(f"Successfully rendered template '{id}'")
  419. # Determine output directory
  420. output_dir = Path(directory) if directory else Path(id)
  421. # Check for conflicts and get confirmation
  422. existing_files = self._check_output_directory(output_dir, rendered_files, interactive)
  423. if existing_files is None:
  424. return # User cancelled
  425. # Get final confirmation for generation
  426. dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
  427. if not self._get_generation_confirmation(output_dir, rendered_files, existing_files,
  428. dir_not_empty, dry_run, interactive):
  429. return # User cancelled
  430. # Execute generation (dry run or actual)
  431. if dry_run:
  432. self._execute_dry_run(id, output_dir, rendered_files, show_files)
  433. else:
  434. self._write_generated_files(output_dir, rendered_files)
  435. # Display next steps
  436. if template.metadata.next_steps:
  437. self.display.display_next_steps(template.metadata.next_steps, variable_values)
  438. except Exception as e:
  439. self.display.display_error(str(e), context=f"generating template '{id}'")
  440. raise Exit(code=1)
  441. def config_get(
  442. self,
  443. var_name: Optional[str] = Argument(None, help="Variable name to get (omit to show all defaults)"),
  444. ) -> None:
  445. """Get default value(s) for this module.
  446. Examples:
  447. # Get all defaults for module
  448. cli compose defaults get
  449. # Get specific variable default
  450. cli compose defaults get service_name
  451. """
  452. from .config import ConfigManager
  453. config = ConfigManager()
  454. if var_name:
  455. # Get specific variable default
  456. value = config.get_default_value(self.name, var_name)
  457. if value is not None:
  458. console.print(f"[green]{var_name}[/green] = [yellow]{value}[/yellow]")
  459. else:
  460. self.display.display_warning(f"No default set for variable '{var_name}'", context=f"module '{self.name}'")
  461. else:
  462. # Show all defaults (flat list)
  463. defaults = config.get_defaults(self.name)
  464. if defaults:
  465. console.print(f"[bold]Config defaults for module '{self.name}':[/bold]\n")
  466. for var_name, var_value in defaults.items():
  467. console.print(f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]")
  468. else:
  469. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  470. def config_set(
  471. self,
  472. var_name: str = Argument(..., help="Variable name or var=value format"),
  473. value: Optional[str] = Argument(None, help="Default value (not needed if using var=value format)"),
  474. ) -> None:
  475. """Set a default value for a variable.
  476. This only sets the DEFAULT VALUE, not the variable spec.
  477. The variable must be defined in the module or template spec.
  478. Supports both formats:
  479. - var_name value
  480. - var_name=value
  481. Examples:
  482. # Set default value (format 1)
  483. cli compose defaults set service_name my-awesome-app
  484. # Set default value (format 2)
  485. cli compose defaults set service_name=my-awesome-app
  486. # Set author for all compose templates
  487. cli compose defaults set author "Christian Lempa"
  488. """
  489. from .config import ConfigManager
  490. config = ConfigManager()
  491. # Parse var_name and value - support both "var value" and "var=value" formats
  492. if '=' in var_name and value is None:
  493. # Format: var_name=value
  494. parts = var_name.split('=', 1)
  495. actual_var_name = parts[0]
  496. actual_value = parts[1]
  497. elif value is not None:
  498. # Format: var_name value
  499. actual_var_name = var_name
  500. actual_value = value
  501. else:
  502. self.display.display_error(f"Missing value for variable '{var_name}'", context="config set")
  503. console.print(f"[dim]Usage: defaults set VAR_NAME VALUE or defaults set VAR_NAME=VALUE[/dim]")
  504. raise Exit(code=1)
  505. # Set the default value
  506. config.set_default_value(self.name, actual_var_name, actual_value)
  507. console.print(f"[green]{IconManager.get_status_icon('success')} Set default:[/green] [cyan]{actual_var_name}[/cyan] = [yellow]{actual_value}[/yellow]")
  508. console.print(f"\n[dim]This will be used as the default value when generating templates with this module.[/dim]")
  509. def config_remove(
  510. self,
  511. var_name: str = Argument(..., help="Variable name to remove"),
  512. ) -> None:
  513. """Remove a specific default variable value.
  514. Examples:
  515. # Remove a default value
  516. cli compose defaults rm service_name
  517. """
  518. from .config import ConfigManager
  519. config = ConfigManager()
  520. defaults = config.get_defaults(self.name)
  521. if not defaults:
  522. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  523. return
  524. if var_name in defaults:
  525. del defaults[var_name]
  526. config.set_defaults(self.name, defaults)
  527. console.print(f"[green]{IconManager.get_status_icon('success')} Removed default for '{var_name}'[/green]")
  528. else:
  529. console.print(f"[red]No default found for variable '{var_name}'[/red]")
  530. def config_clear(
  531. self,
  532. var_name: Optional[str] = Argument(None, help="Variable name to clear (omit to clear all defaults)"),
  533. force: bool = Option(False, "--force", "-f", help="Skip confirmation prompt"),
  534. ) -> None:
  535. """Clear default value(s) for this module.
  536. Examples:
  537. # Clear specific variable default
  538. cli compose defaults clear service_name
  539. # Clear all defaults for module
  540. cli compose defaults clear --force
  541. """
  542. from .config import ConfigManager
  543. config = ConfigManager()
  544. defaults = config.get_defaults(self.name)
  545. if not defaults:
  546. console.print(f"[yellow]No defaults configured for module '{self.name}'[/yellow]")
  547. return
  548. if var_name:
  549. # Clear specific variable
  550. if var_name in defaults:
  551. del defaults[var_name]
  552. config.set_defaults(self.name, defaults)
  553. console.print(f"[green]{IconManager.get_status_icon('success')} Cleared default for '{var_name}'[/green]")
  554. else:
  555. console.print(f"[red]No default found for variable '{var_name}'[/red]")
  556. else:
  557. # Clear all defaults
  558. if not force:
  559. console.print(f"[bold yellow]{IconManager.get_status_icon('warning')} Warning:[/bold yellow] This will clear ALL defaults for module '[cyan]{self.name}[/cyan]'")
  560. console.print()
  561. # Show what will be cleared
  562. for var_name, var_value in defaults.items():
  563. console.print(f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]")
  564. console.print()
  565. if not Confirm.ask(f"[bold red]Are you sure?[/bold red]", default=False):
  566. console.print("[green]Operation cancelled.[/green]")
  567. return
  568. config.clear_defaults(self.name)
  569. console.print(f"[green]{IconManager.get_status_icon('success')} Cleared all defaults for module '{self.name}'[/green]")
  570. def config_list(self) -> None:
  571. """Display the defaults for this specific module in YAML format.
  572. Examples:
  573. # Show the defaults for the current module
  574. cli compose defaults list
  575. """
  576. from .config import ConfigManager
  577. import yaml
  578. config = ConfigManager()
  579. # Get only the defaults for this module
  580. defaults = config.get_defaults(self.name)
  581. if not defaults:
  582. console.print(f"[yellow]No configuration found for module '{self.name}'[/yellow]")
  583. console.print(f"\n[dim]Config file location: {config.get_config_path()}[/dim]")
  584. return
  585. # Create a minimal config structure with only this module's defaults
  586. module_config = {
  587. "defaults": {
  588. self.name: defaults
  589. }
  590. }
  591. # Convert config to YAML string
  592. yaml_output = yaml.dump(module_config, default_flow_style=False, sort_keys=False)
  593. console.print(f"[bold]Configuration for module:[/bold] [cyan]{self.name}[/cyan]")
  594. console.print(f"[dim]Config file: {config.get_config_path()}[/dim]\n")
  595. console.print(Panel(yaml_output, title=f"{self.name.capitalize()} Config", border_style="blue"))
  596. def validate(
  597. self,
  598. template_id: str = Argument(None, help="Template ID to validate (if omitted, validates all templates)"),
  599. verbose: bool = Option(False, "--verbose", "-v", help="Show detailed validation information"),
  600. semantic: bool = Option(True, "--semantic/--no-semantic", help="Enable semantic validation (Docker Compose schema, etc.)")
  601. ) -> None:
  602. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
  603. Validation includes:
  604. - Jinja2 syntax checking
  605. - Variable definition checking
  606. - Semantic validation (when --semantic is enabled):
  607. - Docker Compose file structure
  608. - YAML syntax
  609. - Configuration best practices
  610. Examples:
  611. # Validate all templates in this module
  612. cli compose validate
  613. # Validate a specific template
  614. cli compose validate gitlab
  615. # Validate with verbose output
  616. cli compose validate --verbose
  617. # Skip semantic validation (only Jinja2)
  618. cli compose validate --no-semantic
  619. """
  620. from rich.table import Table
  621. from .validators import get_validator_registry
  622. if template_id:
  623. # Validate a specific template
  624. try:
  625. template = self._load_template_by_id(template_id)
  626. console.print(f"[bold]Validating template:[/bold] [cyan]{template_id}[/cyan]\n")
  627. try:
  628. # Trigger validation by accessing used_variables
  629. _ = template.used_variables
  630. # Trigger variable definition validation by accessing variables
  631. _ = template.variables
  632. console.print(f"[green]{IconManager.get_status_icon('success')} Jinja2 validation passed[/green]")
  633. # Semantic validation
  634. if semantic:
  635. console.print(f"\n[bold cyan]Running semantic validation...[/bold cyan]")
  636. registry = get_validator_registry()
  637. has_semantic_errors = False
  638. # Render template with default values for validation
  639. rendered_files, _ = template.render(template.variables)
  640. for file_path, content in rendered_files.items():
  641. result = registry.validate_file(content, file_path)
  642. if result.errors or result.warnings or (verbose and result.info):
  643. console.print(f"\n[cyan]File:[/cyan] {file_path}")
  644. result.display(f"{file_path}")
  645. if result.errors:
  646. has_semantic_errors = True
  647. if not has_semantic_errors:
  648. console.print(f"\n[green]{IconManager.get_status_icon('success')} Semantic validation passed[/green]")
  649. else:
  650. console.print(f"\n[red]{IconManager.get_status_icon('error')} Semantic validation found errors[/red]")
  651. raise Exit(code=1)
  652. if verbose:
  653. console.print(f"\n[dim]Template path: {template.template_dir}[/dim]")
  654. console.print(f"[dim]Found {len(template.used_variables)} variables[/dim]")
  655. console.print(f"[dim]Generated {len(rendered_files)} files[/dim]")
  656. except ValueError as e:
  657. console.print(f"[red]{IconManager.get_status_icon('error')} Validation failed for '{template_id}':[/red]")
  658. console.print(f"\n{e}")
  659. raise Exit(code=1)
  660. except Exception as e:
  661. console.print(f"[red]Error loading template '{template_id}': {e}[/red]")
  662. raise Exit(code=1)
  663. else:
  664. # Validate all templates
  665. console.print(f"[bold]Validating all {self.name} templates...[/bold]\n")
  666. entries = self.libraries.find(self.name, sort_results=True)
  667. total = len(entries)
  668. valid_count = 0
  669. invalid_count = 0
  670. errors = []
  671. for template_dir, library_name in entries:
  672. template_id = template_dir.name
  673. try:
  674. template = Template(template_dir, library_name=library_name)
  675. # Trigger validation
  676. _ = template.used_variables
  677. _ = template.variables
  678. valid_count += 1
  679. if verbose:
  680. console.print(f"[green]{IconManager.get_status_icon('success')}[/green] {template_id}")
  681. except ValueError as e:
  682. invalid_count += 1
  683. errors.append((template_id, str(e)))
  684. if verbose:
  685. console.print(f"[red]{IconManager.get_status_icon('error')}[/red] {template_id}")
  686. except Exception as e:
  687. invalid_count += 1
  688. errors.append((template_id, f"Load error: {e}"))
  689. if verbose:
  690. console.print(f"[yellow]{IconManager.get_status_icon('warning')}[/yellow] {template_id}")
  691. # Summary
  692. console.print(f"\n[bold]Validation Summary:[/bold]")
  693. summary_table = Table(show_header=False, box=None, padding=(0, 2))
  694. summary_table.add_column(style="bold")
  695. summary_table.add_column()
  696. summary_table.add_row("Total templates:", str(total))
  697. summary_table.add_row("[green]Valid:[/green]", str(valid_count))
  698. summary_table.add_row("[red]Invalid:[/red]", str(invalid_count))
  699. console.print(summary_table)
  700. # Show errors if any
  701. if errors:
  702. console.print(f"\n[bold red]Validation Errors:[/bold red]")
  703. for template_id, error_msg in errors:
  704. console.print(f"\n[yellow]Template:[/yellow] [cyan]{template_id}[/cyan]")
  705. console.print(f"[dim]{error_msg}[/dim]")
  706. raise Exit(code=1)
  707. else:
  708. console.print(f"\n[green]{IconManager.get_status_icon('success')} All templates are valid![/green]")
  709. @classmethod
  710. def register_cli(cls, app: Typer) -> None:
  711. """Register module commands with the main app."""
  712. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  713. module_instance = cls()
  714. module_app = Typer(help=cls.description)
  715. module_app.command("list")(module_instance.list)
  716. module_app.command("search")(module_instance.search)
  717. module_app.command("show")(module_instance.show)
  718. module_app.command("validate")(module_instance.validate)
  719. module_app.command(
  720. "generate",
  721. context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
  722. )(module_instance.generate)
  723. # Add defaults commands (simplified - only manage default values)
  724. defaults_app = Typer(help="Manage default values for template variables")
  725. defaults_app.command("get", help="Get default value(s)")(module_instance.config_get)
  726. defaults_app.command("set", help="Set a default value")(module_instance.config_set)
  727. defaults_app.command("rm", help="Remove a specific default value")(module_instance.config_remove)
  728. defaults_app.command("clear", help="Clear default value(s)")(module_instance.config_clear)
  729. defaults_app.command("list", help="Display the config for this module in YAML format")(module_instance.config_list)
  730. module_app.add_typer(defaults_app, name="defaults")
  731. app.add_typer(module_app, name=cls.name, help=cls.description)
  732. logger.info(f"Module '{cls.name}' CLI commands registered")
  733. def _load_template_by_id(self, id: str) -> Template:
  734. result = self.libraries.find_by_id(self.name, id)
  735. if not result:
  736. raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
  737. template_dir, library_name = result
  738. try:
  739. return Template(template_dir, library_name=library_name)
  740. except Exception as exc:
  741. logger.error(f"Failed to load template '{id}': {exc}")
  742. raise FileNotFoundError(f"Template '{id}' could not be loaded: {exc}") from exc
  743. def _display_template_details(self, template: Template, id: str) -> None:
  744. """Display template information panel and variables table."""
  745. self.display.display_template_details(template, id)