module.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380
  1. from __future__ import annotations
  2. import logging
  3. from abc import ABC
  4. from pathlib import Path
  5. from typing import Any, Optional, List, Dict
  6. import yaml
  7. from rich.console import Console
  8. from rich.panel import Panel
  9. from rich.prompt import Confirm
  10. from typer import Argument, Option, Typer, Exit
  11. from .display import DisplayManager
  12. from .exceptions import (
  13. TemplateRenderError,
  14. TemplateSyntaxError,
  15. TemplateValidationError,
  16. )
  17. from .library import LibraryManager
  18. from .prompt import PromptHandler
  19. from .template import Template
  20. logger = logging.getLogger(__name__)
  21. console = Console()
  22. console_err = Console(stderr=True)
  23. def parse_var_inputs(var_options: List[str], extra_args: List[str]) -> Dict[str, Any]:
  24. """Parse variable inputs from --var options and extra args.
  25. Supports formats:
  26. --var KEY=VALUE
  27. --var KEY VALUE
  28. Args:
  29. var_options: List of variable options from CLI
  30. extra_args: Additional arguments that may contain values
  31. Returns:
  32. Dictionary of parsed variables
  33. """
  34. variables = {}
  35. # Parse --var KEY=VALUE format
  36. for var_option in var_options:
  37. if "=" in var_option:
  38. key, value = var_option.split("=", 1)
  39. variables[key] = value
  40. else:
  41. # --var KEY VALUE format - value should be in extra_args
  42. if extra_args:
  43. variables[var_option] = extra_args.pop(0)
  44. else:
  45. logger.warning(f"No value provided for variable '{var_option}'")
  46. return variables
  47. class Module(ABC):
  48. """Streamlined base module that auto-detects variables from templates."""
  49. # Schema version supported by this module (override in subclasses)
  50. schema_version: str = "1.0"
  51. def __init__(self) -> None:
  52. if not all([self.name, self.description]):
  53. raise ValueError(
  54. f"Module {self.__class__.__name__} must define name and description"
  55. )
  56. logger.info(f"Initializing module '{self.name}'")
  57. logger.debug(
  58. f"Module '{self.name}' configuration: description='{self.description}'"
  59. )
  60. self.libraries = LibraryManager()
  61. self.display = DisplayManager()
  62. def _load_all_templates(self, filter_fn=None) -> list[Template]:
  63. """Load all templates for this module with optional filtering.
  64. This centralized method eliminates duplicate template loading logic
  65. across list(), search(), and validate() methods.
  66. Args:
  67. filter_fn: Optional function to filter templates. Takes Template and
  68. returns bool. Only templates where filter_fn returns True
  69. are included.
  70. Returns:
  71. List of successfully loaded Template objects
  72. Example:
  73. # Load all templates
  74. templates = self._load_all_templates()
  75. # Load only templates matching a search query
  76. templates = self._load_all_templates(
  77. lambda t: "nginx" in t.id.lower()
  78. )
  79. """
  80. templates = []
  81. entries = self.libraries.find(self.name, sort_results=True)
  82. for entry in entries:
  83. # Unpack entry - returns (path, library_name, needs_qualification)
  84. template_dir = entry[0]
  85. library_name = entry[1]
  86. needs_qualification = entry[2] if len(entry) > 2 else False
  87. try:
  88. # Get library object to determine type
  89. library = next(
  90. (
  91. lib
  92. for lib in self.libraries.libraries
  93. if lib.name == library_name
  94. ),
  95. None,
  96. )
  97. library_type = library.library_type if library else "git"
  98. template = Template(
  99. template_dir, library_name=library_name, library_type=library_type
  100. )
  101. # Validate schema version compatibility
  102. template._validate_schema_version(self.schema_version, self.name)
  103. # If template ID needs qualification, set qualified ID
  104. if needs_qualification:
  105. template.set_qualified_id()
  106. # Apply filter if provided
  107. if filter_fn is None or filter_fn(template):
  108. templates.append(template)
  109. except Exception as exc:
  110. logger.error(f"Failed to load template from {template_dir}: {exc}")
  111. continue
  112. return templates
  113. def list(
  114. self,
  115. raw: bool = Option(
  116. False, "--raw", help="Output raw list format instead of rich table"
  117. ),
  118. ) -> list[Template]:
  119. """List all templates."""
  120. logger.debug(f"Listing templates for module '{self.name}'")
  121. # Load all templates using centralized helper
  122. filtered_templates = self._load_all_templates()
  123. if filtered_templates:
  124. if raw:
  125. # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
  126. # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
  127. for template in filtered_templates:
  128. name = template.metadata.name or "Unnamed Template"
  129. tags_list = template.metadata.tags or []
  130. tags = ",".join(tags_list) if tags_list else "-"
  131. version = (
  132. str(template.metadata.version)
  133. if template.metadata.version
  134. else "-"
  135. )
  136. library = template.metadata.library or "-"
  137. print(f"{template.id}\t{name}\t{tags}\t{version}\t{library}")
  138. else:
  139. # Output rich table format
  140. self.display.display_templates_table(
  141. filtered_templates, self.name, f"{self.name.capitalize()} templates"
  142. )
  143. else:
  144. logger.info(f"No templates found for module '{self.name}'")
  145. return filtered_templates
  146. def search(
  147. self, query: str = Argument(..., help="Search string to filter templates by ID")
  148. ) -> list[Template]:
  149. """Search for templates by ID containing the search string."""
  150. logger.debug(
  151. f"Searching templates for module '{self.name}' with query='{query}'"
  152. )
  153. # Load templates with search filter using centralized helper
  154. filtered_templates = self._load_all_templates(
  155. lambda t: query.lower() in t.id.lower()
  156. )
  157. if filtered_templates:
  158. logger.info(
  159. f"Found {len(filtered_templates)} templates matching '{query}' for module '{self.name}'"
  160. )
  161. self.display.display_templates_table(
  162. filtered_templates,
  163. self.name,
  164. f"{self.name.capitalize()} templates matching '{query}'",
  165. )
  166. else:
  167. logger.info(
  168. f"No templates found matching '{query}' for module '{self.name}'"
  169. )
  170. self.display.display_warning(
  171. f"No templates found matching '{query}'",
  172. context=f"module '{self.name}'",
  173. )
  174. return filtered_templates
  175. def show(
  176. self,
  177. id: str,
  178. ) -> None:
  179. """Show template details."""
  180. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  181. template = self._load_template_by_id(id)
  182. if not template:
  183. self.display.display_error(
  184. f"Template '{id}' not found", context=f"module '{self.name}'"
  185. )
  186. return
  187. # Apply config defaults (same as in generate)
  188. # This ensures the display shows the actual defaults that will be used
  189. if template.variables:
  190. from .config import ConfigManager
  191. config = ConfigManager()
  192. config_defaults = config.get_defaults(self.name)
  193. if config_defaults:
  194. logger.debug(f"Loading config defaults for module '{self.name}'")
  195. # Apply config defaults (this respects the variable types and validation)
  196. successful = template.variables.apply_defaults(
  197. config_defaults, "config"
  198. )
  199. if successful:
  200. logger.debug(
  201. f"Applied config defaults for: {', '.join(successful)}"
  202. )
  203. # Re-sort sections after applying config (toggle values may have changed)
  204. template.variables.sort_sections()
  205. # Reset disabled bool variables to False to prevent confusion
  206. reset_vars = template.variables.reset_disabled_bool_variables()
  207. if reset_vars:
  208. logger.debug(
  209. f"Reset {len(reset_vars)} disabled bool variables to False"
  210. )
  211. self.display.display_template(template, id)
  212. def _apply_variable_defaults(self, template: Template) -> None:
  213. """Apply config defaults and CLI overrides to template variables.
  214. Args:
  215. template: Template instance with variables to configure
  216. """
  217. if not template.variables:
  218. return
  219. from .config import ConfigManager
  220. config = ConfigManager()
  221. config_defaults = config.get_defaults(self.name)
  222. if config_defaults:
  223. logger.info(f"Loading config defaults for module '{self.name}'")
  224. successful = template.variables.apply_defaults(config_defaults, "config")
  225. if successful:
  226. logger.debug(f"Applied config defaults for: {', '.join(successful)}")
  227. def _load_var_file(self, var_file_path: str) -> dict:
  228. """Load variables from a YAML file.
  229. Args:
  230. var_file_path: Path to the YAML file containing variables
  231. Returns:
  232. Dictionary of variable names to values (flat structure)
  233. Raises:
  234. FileNotFoundError: If the var file doesn't exist
  235. ValueError: If the file is not valid YAML or has invalid structure
  236. """
  237. var_path = Path(var_file_path).expanduser().resolve()
  238. if not var_path.exists():
  239. raise FileNotFoundError(f"Variable file not found: {var_file_path}")
  240. if not var_path.is_file():
  241. raise ValueError(f"Variable file path is not a file: {var_file_path}")
  242. try:
  243. with open(var_path, "r", encoding="utf-8") as f:
  244. content = yaml.safe_load(f)
  245. except yaml.YAMLError as e:
  246. raise ValueError(f"Invalid YAML in variable file: {e}") from e
  247. except (IOError, OSError) as e:
  248. raise ValueError(f"Error reading variable file: {e}") from e
  249. if not isinstance(content, dict):
  250. raise ValueError(
  251. f"Variable file must contain a YAML dictionary, got {type(content).__name__}"
  252. )
  253. logger.info(f"Loaded {len(content)} variables from file: {var_path.name}")
  254. logger.debug(f"Variables from file: {', '.join(content.keys())}")
  255. return content
  256. def _apply_var_file(self, template: Template, var_file_path: Optional[str]) -> None:
  257. """Apply variables from a YAML file to template.
  258. Args:
  259. template: Template instance to apply variables to
  260. var_file_path: Path to the YAML file containing variables
  261. Raises:
  262. Exit: If the file cannot be loaded or contains invalid data
  263. """
  264. if not var_file_path or not template.variables:
  265. return
  266. try:
  267. var_file_vars = self._load_var_file(var_file_path)
  268. if var_file_vars:
  269. # Get list of valid variable names from template
  270. valid_vars = set()
  271. for section in template.variables.get_sections().values():
  272. valid_vars.update(section.variables.keys())
  273. # Warn about unknown variables
  274. unknown_vars = set(var_file_vars.keys()) - valid_vars
  275. if unknown_vars:
  276. for var_name in sorted(unknown_vars):
  277. logger.warning(
  278. f"Variable '{var_name}' from var-file does not exist in template '{template.id}'"
  279. )
  280. successful = template.variables.apply_defaults(
  281. var_file_vars, "var-file"
  282. )
  283. if successful:
  284. logger.debug(
  285. f"Applied var-file overrides for: {', '.join(successful)}"
  286. )
  287. except (FileNotFoundError, ValueError) as e:
  288. self.display.display_error(
  289. f"Failed to load variable file: {e}",
  290. context="variable file loading",
  291. )
  292. raise Exit(code=1) from e
  293. def _apply_cli_overrides(
  294. self, template: Template, var: Optional[List[str]], ctx=None
  295. ) -> None:
  296. """Apply CLI variable overrides to template.
  297. Args:
  298. template: Template instance to apply overrides to
  299. var: List of variable override strings from --var flags
  300. ctx: Context object containing extra args (optional, will get current context if None)
  301. """
  302. if not template.variables:
  303. return
  304. # Get context if not provided (compatible with all Typer versions)
  305. if ctx is None:
  306. import click
  307. try:
  308. ctx = click.get_current_context()
  309. except RuntimeError:
  310. ctx = None
  311. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  312. cli_overrides = parse_var_inputs(var or [], extra_args)
  313. if cli_overrides:
  314. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  315. successful_overrides = template.variables.apply_defaults(
  316. cli_overrides, "cli"
  317. )
  318. if successful_overrides:
  319. logger.debug(
  320. f"Applied CLI overrides for: {', '.join(successful_overrides)}"
  321. )
  322. def _collect_variable_values(
  323. self, template: Template, interactive: bool
  324. ) -> Dict[str, Any]:
  325. """Collect variable values from user prompts and template defaults.
  326. Args:
  327. template: Template instance with variables
  328. interactive: Whether to prompt user for values interactively
  329. Returns:
  330. Dictionary of variable names to values
  331. """
  332. variable_values = {}
  333. # Collect values interactively if enabled
  334. if interactive and template.variables:
  335. prompt_handler = PromptHandler()
  336. collected_values = prompt_handler.collect_variables(template.variables)
  337. if collected_values:
  338. variable_values.update(collected_values)
  339. logger.info(
  340. f"Collected {len(collected_values)} variable values from user input"
  341. )
  342. # Add satisfied variable values (respects dependencies and toggles)
  343. if template.variables:
  344. variable_values.update(template.variables.get_satisfied_values())
  345. return variable_values
  346. def _check_output_directory(
  347. self, output_dir: Path, rendered_files: Dict[str, str], interactive: bool
  348. ) -> Optional[List[Path]]:
  349. """Check output directory for conflicts and get user confirmation if needed.
  350. Args:
  351. output_dir: Directory where files will be written
  352. rendered_files: Dictionary of file paths to rendered content
  353. interactive: Whether to prompt user for confirmation
  354. Returns:
  355. List of existing files that will be overwritten, or None to cancel
  356. """
  357. dir_exists = output_dir.exists()
  358. dir_not_empty = dir_exists and any(output_dir.iterdir())
  359. # Check which files already exist
  360. existing_files = []
  361. if dir_exists:
  362. for file_path in rendered_files.keys():
  363. full_path = output_dir / file_path
  364. if full_path.exists():
  365. existing_files.append(full_path)
  366. # Warn if directory is not empty
  367. if dir_not_empty:
  368. if interactive:
  369. details = []
  370. if existing_files:
  371. details.append(
  372. f"{len(existing_files)} file(s) will be overwritten."
  373. )
  374. if not self.display.display_warning_with_confirmation(
  375. f"Directory '{output_dir}' is not empty.",
  376. details if details else None,
  377. default=False,
  378. ):
  379. self.display.display_info("Generation cancelled")
  380. return None
  381. else:
  382. # Non-interactive mode: show warning but continue
  383. logger.warning(f"Directory '{output_dir}' is not empty")
  384. if existing_files:
  385. logger.warning(f"{len(existing_files)} file(s) will be overwritten")
  386. return existing_files
  387. def _get_generation_confirmation(
  388. self,
  389. output_dir: Path,
  390. rendered_files: Dict[str, str],
  391. existing_files: Optional[List[Path]],
  392. dir_not_empty: bool,
  393. dry_run: bool,
  394. interactive: bool,
  395. ) -> bool:
  396. """Display file generation confirmation and get user approval.
  397. Args:
  398. output_dir: Output directory path
  399. rendered_files: Dictionary of file paths to content
  400. existing_files: List of existing files that will be overwritten
  401. dir_not_empty: Whether output directory already contains files
  402. dry_run: Whether this is a dry run
  403. interactive: Whether to prompt for confirmation
  404. Returns:
  405. True if user confirms generation, False to cancel
  406. """
  407. if not interactive:
  408. return True
  409. self.display.display_file_generation_confirmation(
  410. output_dir, rendered_files, existing_files if existing_files else None
  411. )
  412. # Final confirmation (only if we didn't already ask about overwriting)
  413. if not dir_not_empty and not dry_run:
  414. if not Confirm.ask("Generate these files?", default=True):
  415. self.display.display_info("Generation cancelled")
  416. return False
  417. return True
  418. def _execute_dry_run(
  419. self,
  420. id: str,
  421. output_dir: Path,
  422. rendered_files: Dict[str, str],
  423. show_files: bool,
  424. ) -> None:
  425. """Execute dry run mode with comprehensive simulation.
  426. Simulates all filesystem operations that would occur during actual generation,
  427. including directory creation, file writing, and permission checks.
  428. Args:
  429. id: Template ID
  430. output_dir: Directory where files would be written
  431. rendered_files: Dictionary of file paths to rendered content
  432. show_files: Whether to display file contents
  433. """
  434. import os
  435. console.print()
  436. console.print(
  437. "[bold cyan]Dry Run Mode - Simulating File Generation[/bold cyan]"
  438. )
  439. console.print()
  440. # Simulate directory creation
  441. self.display.display_heading("Directory Operations", icon_type="folder")
  442. # Check if output directory exists
  443. if output_dir.exists():
  444. self.display.display_success(
  445. f"Output directory exists: [cyan]{output_dir}[/cyan]"
  446. )
  447. # Check if we have write permissions
  448. if os.access(output_dir, os.W_OK):
  449. self.display.display_success("Write permission verified")
  450. else:
  451. self.display.display_warning("Write permission may be denied")
  452. else:
  453. console.print(
  454. f" [dim]→[/dim] Would create output directory: [cyan]{output_dir}[/cyan]"
  455. )
  456. # Check if parent directory exists and is writable
  457. parent = output_dir.parent
  458. if parent.exists() and os.access(parent, os.W_OK):
  459. self.display.display_success("Parent directory writable")
  460. else:
  461. self.display.display_warning("Parent directory may not be writable")
  462. # Collect unique subdirectories that would be created
  463. subdirs = set()
  464. for file_path in rendered_files.keys():
  465. parts = Path(file_path).parts
  466. for i in range(1, len(parts)):
  467. subdirs.add(Path(*parts[:i]))
  468. if subdirs:
  469. console.print(
  470. f" [dim]→[/dim] Would create {len(subdirs)} subdirectory(ies)"
  471. )
  472. for subdir in sorted(subdirs):
  473. console.print(f" [dim]📁[/dim] {subdir}/")
  474. console.print()
  475. # Display file operations in a table
  476. self.display.display_heading("File Operations", icon_type="file")
  477. total_size = 0
  478. new_files = 0
  479. overwrite_files = 0
  480. file_operations = []
  481. for file_path, content in sorted(rendered_files.items()):
  482. full_path = output_dir / file_path
  483. file_size = len(content.encode("utf-8"))
  484. total_size += file_size
  485. # Determine status
  486. if full_path.exists():
  487. status = "Overwrite"
  488. overwrite_files += 1
  489. else:
  490. status = "Create"
  491. new_files += 1
  492. file_operations.append((file_path, file_size, status))
  493. self.display.display_file_operation_table(file_operations)
  494. console.print()
  495. # Summary statistics
  496. if total_size < 1024:
  497. size_str = f"{total_size}B"
  498. elif total_size < 1024 * 1024:
  499. size_str = f"{total_size / 1024:.1f}KB"
  500. else:
  501. size_str = f"{total_size / (1024 * 1024):.1f}MB"
  502. summary_items = {
  503. "Total files:": str(len(rendered_files)),
  504. "New files:": str(new_files),
  505. "Files to overwrite:": str(overwrite_files),
  506. "Total size:": size_str,
  507. }
  508. self.display.display_summary_table("Summary", summary_items)
  509. console.print()
  510. # Show file contents if requested
  511. if show_files:
  512. console.print("[bold cyan]Generated File Contents:[/bold cyan]")
  513. console.print()
  514. for file_path, content in sorted(rendered_files.items()):
  515. console.print(f"[cyan]File:[/cyan] {file_path}")
  516. print(f"{'─' * 80}")
  517. print(content)
  518. print() # Add blank line after content
  519. console.print()
  520. self.display.display_success("Dry run complete - no files were written")
  521. console.print(f"[dim]Files would have been generated in '{output_dir}'[/dim]")
  522. logger.info(
  523. f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes"
  524. )
  525. def _write_generated_files(
  526. self, output_dir: Path, rendered_files: Dict[str, str], quiet: bool = False
  527. ) -> None:
  528. """Write rendered files to the output directory.
  529. Args:
  530. output_dir: Directory to write files to
  531. rendered_files: Dictionary of file paths to rendered content
  532. quiet: Suppress output messages
  533. """
  534. output_dir.mkdir(parents=True, exist_ok=True)
  535. for file_path, content in rendered_files.items():
  536. full_path = output_dir / file_path
  537. full_path.parent.mkdir(parents=True, exist_ok=True)
  538. with open(full_path, "w", encoding="utf-8") as f:
  539. f.write(content)
  540. if not quiet:
  541. console.print(
  542. f"[green]Generated file: {file_path}[/green]"
  543. ) # Keep simple per-file output
  544. if not quiet:
  545. self.display.display_success(
  546. f"Template generated successfully in '{output_dir}'"
  547. )
  548. logger.info(f"Template written to directory: {output_dir}")
  549. def generate(
  550. self,
  551. id: str = Argument(..., help="Template ID"),
  552. directory: Optional[str] = Argument(
  553. None, help="Output directory (defaults to template ID)"
  554. ),
  555. interactive: bool = Option(
  556. True,
  557. "--interactive/--no-interactive",
  558. "-i/-n",
  559. help="Enable interactive prompting for variables",
  560. ),
  561. var: Optional[list[str]] = Option(
  562. None,
  563. "--var",
  564. "-v",
  565. help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
  566. ),
  567. var_file: Optional[str] = Option(
  568. None,
  569. "--var-file",
  570. "-f",
  571. help="Load variables from YAML file (overrides config defaults, overridden by --var)",
  572. ),
  573. dry_run: bool = Option(
  574. False, "--dry-run", help="Preview template generation without writing files"
  575. ),
  576. show_files: bool = Option(
  577. False,
  578. "--show-files",
  579. help="Display generated file contents in plain text (use with --dry-run)",
  580. ),
  581. quiet: bool = Option(
  582. False, "--quiet", "-q", help="Suppress all non-error output"
  583. ),
  584. ) -> None:
  585. """Generate from template.
  586. Variable precedence chain (lowest to highest):
  587. 1. Module spec (defined in cli/modules/*.py)
  588. 2. Template spec (from template.yaml)
  589. 3. Config defaults (from ~/.config/boilerplates/config.yaml)
  590. 4. Variable file (from --var-file)
  591. 5. CLI overrides (--var flags)
  592. Examples:
  593. # Generate to directory named after template
  594. cli compose generate traefik
  595. # Generate to custom directory
  596. cli compose generate traefik my-proxy
  597. # Generate with variables from file
  598. cli compose generate traefik --var-file values.yaml
  599. # Generate with variables from file and CLI overrides
  600. cli compose generate traefik --var-file values.yaml --var traefik_enabled=false
  601. # Preview without writing files (dry run)
  602. cli compose generate traefik --dry-run
  603. # Preview and show generated file contents
  604. cli compose generate traefik --dry-run --show-files
  605. """
  606. logger.info(
  607. f"Starting generation for template '{id}' from module '{self.name}'"
  608. )
  609. # Create a display manager with quiet mode if needed
  610. display = DisplayManager(quiet=quiet) if quiet else self.display
  611. template = self._load_template_by_id(id)
  612. # Apply defaults and overrides (in precedence order)
  613. self._apply_variable_defaults(template)
  614. self._apply_var_file(template, var_file)
  615. self._apply_cli_overrides(template, var)
  616. # Re-sort sections after all overrides (toggle values may have changed)
  617. if template.variables:
  618. template.variables.sort_sections()
  619. # Reset disabled bool variables to False to prevent confusion
  620. reset_vars = template.variables.reset_disabled_bool_variables()
  621. if reset_vars:
  622. logger.debug(
  623. f"Reset {len(reset_vars)} disabled bool variables to False"
  624. )
  625. if not quiet:
  626. self.display.display_template(template, id)
  627. console.print()
  628. # Collect variable values
  629. variable_values = self._collect_variable_values(template, interactive)
  630. try:
  631. # Validate and render template
  632. if template.variables:
  633. template.variables.validate_all()
  634. # Check if we're in debug mode (logger level is DEBUG)
  635. debug_mode = logger.isEnabledFor(logging.DEBUG)
  636. rendered_files, variable_values = template.render(
  637. template.variables, debug=debug_mode
  638. )
  639. if not rendered_files:
  640. display.display_error(
  641. "Template rendering returned no files",
  642. context="template generation",
  643. )
  644. raise Exit(code=1)
  645. logger.info(f"Successfully rendered template '{id}'")
  646. # Determine output directory
  647. if directory:
  648. output_dir = Path(directory)
  649. # Check if path looks like an absolute path but is missing the leading slash
  650. # This handles cases like "Users/username/path" which should be "/Users/username/path"
  651. if not output_dir.is_absolute() and str(output_dir).startswith(
  652. ("Users/", "home/", "usr/", "opt/", "var/", "tmp/")
  653. ):
  654. output_dir = Path("/") / output_dir
  655. logger.debug(
  656. f"Normalized relative-looking absolute path to: {output_dir}"
  657. )
  658. else:
  659. output_dir = Path(id)
  660. # Check for conflicts and get confirmation (skip in quiet mode)
  661. if not quiet:
  662. existing_files = self._check_output_directory(
  663. output_dir, rendered_files, interactive
  664. )
  665. if existing_files is None:
  666. return # User cancelled
  667. # Get final confirmation for generation
  668. dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
  669. if not self._get_generation_confirmation(
  670. output_dir,
  671. rendered_files,
  672. existing_files,
  673. dir_not_empty,
  674. dry_run,
  675. interactive,
  676. ):
  677. return # User cancelled
  678. else:
  679. # In quiet mode, just check for existing files without prompts
  680. existing_files = []
  681. # Execute generation (dry run or actual)
  682. if dry_run:
  683. if not quiet:
  684. self._execute_dry_run(id, output_dir, rendered_files, show_files)
  685. else:
  686. self._write_generated_files(output_dir, rendered_files, quiet=quiet)
  687. # Display next steps (not in quiet mode)
  688. if template.metadata.next_steps and not quiet:
  689. display.display_next_steps(
  690. template.metadata.next_steps, variable_values
  691. )
  692. except TemplateRenderError as e:
  693. # Display enhanced error information for template rendering errors (always show errors)
  694. display.display_template_render_error(e, context=f"template '{id}'")
  695. raise Exit(code=1)
  696. except Exception as e:
  697. display.display_error(str(e), context=f"generating template '{id}'")
  698. raise Exit(code=1)
  699. def config_get(
  700. self,
  701. var_name: Optional[str] = Argument(
  702. None, help="Variable name to get (omit to show all defaults)"
  703. ),
  704. ) -> None:
  705. """Get default value(s) for this module.
  706. Examples:
  707. # Get all defaults for module
  708. cli compose defaults get
  709. # Get specific variable default
  710. cli compose defaults get service_name
  711. """
  712. from .config import ConfigManager
  713. config = ConfigManager()
  714. if var_name:
  715. # Get specific variable default
  716. value = config.get_default_value(self.name, var_name)
  717. if value is not None:
  718. console.print(f"[green]{var_name}[/green] = [yellow]{value}[/yellow]")
  719. else:
  720. self.display.display_warning(
  721. f"No default set for variable '{var_name}'",
  722. context=f"module '{self.name}'",
  723. )
  724. else:
  725. # Show all defaults (flat list)
  726. defaults = config.get_defaults(self.name)
  727. if defaults:
  728. console.print(
  729. f"[bold]Config defaults for module '{self.name}':[/bold]\n"
  730. )
  731. for var_name, var_value in defaults.items():
  732. console.print(
  733. f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]"
  734. )
  735. else:
  736. console.print(
  737. f"[yellow]No defaults configured for module '{self.name}'[/yellow]"
  738. )
  739. def config_set(
  740. self,
  741. var_name: str = Argument(..., help="Variable name or var=value format"),
  742. value: Optional[str] = Argument(
  743. None, help="Default value (not needed if using var=value format)"
  744. ),
  745. ) -> None:
  746. """Set a default value for a variable.
  747. This only sets the DEFAULT VALUE, not the variable spec.
  748. The variable must be defined in the module or template spec.
  749. Supports both formats:
  750. - var_name value
  751. - var_name=value
  752. Examples:
  753. # Set default value (format 1)
  754. cli compose defaults set service_name my-awesome-app
  755. # Set default value (format 2)
  756. cli compose defaults set service_name=my-awesome-app
  757. # Set author for all compose templates
  758. cli compose defaults set author "Christian Lempa"
  759. """
  760. from .config import ConfigManager
  761. config = ConfigManager()
  762. # Parse var_name and value - support both "var value" and "var=value" formats
  763. if "=" in var_name and value is None:
  764. # Format: var_name=value
  765. parts = var_name.split("=", 1)
  766. actual_var_name = parts[0]
  767. actual_value = parts[1]
  768. elif value is not None:
  769. # Format: var_name value
  770. actual_var_name = var_name
  771. actual_value = value
  772. else:
  773. self.display.display_error(
  774. f"Missing value for variable '{var_name}'", context="config set"
  775. )
  776. console.print(
  777. "[dim]Usage: defaults set VAR_NAME VALUE or defaults set VAR_NAME=VALUE[/dim]"
  778. )
  779. raise Exit(code=1)
  780. # Set the default value
  781. config.set_default_value(self.name, actual_var_name, actual_value)
  782. self.display.display_success(
  783. f"Set default: [cyan]{actual_var_name}[/cyan] = [yellow]{actual_value}[/yellow]"
  784. )
  785. console.print(
  786. "\n[dim]This will be used as the default value when generating templates with this module.[/dim]"
  787. )
  788. def config_remove(
  789. self,
  790. var_name: str = Argument(..., help="Variable name to remove"),
  791. ) -> None:
  792. """Remove a specific default variable value.
  793. Examples:
  794. # Remove a default value
  795. cli compose defaults rm service_name
  796. """
  797. from .config import ConfigManager
  798. config = ConfigManager()
  799. defaults = config.get_defaults(self.name)
  800. if not defaults:
  801. console.print(
  802. f"[yellow]No defaults configured for module '{self.name}'[/yellow]"
  803. )
  804. return
  805. if var_name in defaults:
  806. del defaults[var_name]
  807. config.set_defaults(self.name, defaults)
  808. self.display.display_success(f"Removed default for '{var_name}'")
  809. else:
  810. self.display.display_error(f"No default found for variable '{var_name}'")
  811. def config_clear(
  812. self,
  813. var_name: Optional[str] = Argument(
  814. None, help="Variable name to clear (omit to clear all defaults)"
  815. ),
  816. force: bool = Option(False, "--force", "-f", help="Skip confirmation prompt"),
  817. ) -> None:
  818. """Clear default value(s) for this module.
  819. Examples:
  820. # Clear specific variable default
  821. cli compose defaults clear service_name
  822. # Clear all defaults for module
  823. cli compose defaults clear --force
  824. """
  825. from .config import ConfigManager
  826. config = ConfigManager()
  827. defaults = config.get_defaults(self.name)
  828. if not defaults:
  829. console.print(
  830. f"[yellow]No defaults configured for module '{self.name}'[/yellow]"
  831. )
  832. return
  833. if var_name:
  834. # Clear specific variable
  835. if var_name in defaults:
  836. del defaults[var_name]
  837. config.set_defaults(self.name, defaults)
  838. self.display.display_success(f"Cleared default for '{var_name}'")
  839. else:
  840. self.display.display_error(
  841. f"No default found for variable '{var_name}'"
  842. )
  843. else:
  844. # Clear all defaults
  845. if not force:
  846. detail_lines = [
  847. f"This will clear ALL defaults for module '{self.name}':",
  848. "",
  849. ]
  850. for var_name, var_value in defaults.items():
  851. detail_lines.append(
  852. f" [green]{var_name}[/green] = [yellow]{var_value}[/yellow]"
  853. )
  854. self.display.display_warning("Warning: This will clear ALL defaults")
  855. console.print()
  856. for line in detail_lines:
  857. console.print(line)
  858. console.print()
  859. if not Confirm.ask("[bold red]Are you sure?[/bold red]", default=False):
  860. console.print("[green]Operation cancelled.[/green]")
  861. return
  862. config.clear_defaults(self.name)
  863. self.display.display_success(
  864. f"Cleared all defaults for module '{self.name}'"
  865. )
  866. def config_list(self) -> None:
  867. """Display the defaults for this specific module in YAML format.
  868. Examples:
  869. # Show the defaults for the current module
  870. cli compose defaults list
  871. """
  872. from .config import ConfigManager
  873. import yaml
  874. config = ConfigManager()
  875. # Get only the defaults for this module
  876. defaults = config.get_defaults(self.name)
  877. if not defaults:
  878. console.print(
  879. f"[yellow]No configuration found for module '{self.name}'[/yellow]"
  880. )
  881. console.print(
  882. f"\n[dim]Config file location: {config.get_config_path()}[/dim]"
  883. )
  884. return
  885. # Create a minimal config structure with only this module's defaults
  886. module_config = {"defaults": {self.name: defaults}}
  887. # Convert config to YAML string
  888. yaml_output = yaml.dump(
  889. module_config, default_flow_style=False, sort_keys=False
  890. )
  891. console.print(
  892. f"[bold]Configuration for module:[/bold] [cyan]{self.name}[/cyan]"
  893. )
  894. console.print(f"[dim]Config file: {config.get_config_path()}[/dim]\n")
  895. console.print(
  896. Panel(
  897. yaml_output,
  898. title=f"{self.name.capitalize()} Config",
  899. border_style="blue",
  900. )
  901. )
  902. def validate(
  903. self,
  904. template_id: str = Argument(
  905. None, help="Template ID to validate (if omitted, validates all templates)"
  906. ),
  907. path: Optional[str] = Option(
  908. None,
  909. "--path",
  910. "-p",
  911. help="Validate a template from a specific directory path",
  912. ),
  913. verbose: bool = Option(
  914. False, "--verbose", "-v", help="Show detailed validation information"
  915. ),
  916. semantic: bool = Option(
  917. True,
  918. "--semantic/--no-semantic",
  919. help="Enable semantic validation (Docker Compose schema, etc.)",
  920. ),
  921. ) -> None:
  922. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
  923. Validation includes:
  924. - Jinja2 syntax checking
  925. - Variable definition checking
  926. - Semantic validation (when --semantic is enabled):
  927. - Docker Compose file structure
  928. - YAML syntax
  929. - Configuration best practices
  930. Examples:
  931. # Validate all templates in this module
  932. cli compose validate
  933. # Validate a specific template
  934. cli compose validate gitlab
  935. # Validate a template from a specific path
  936. cli compose validate --path /path/to/template
  937. # Validate with verbose output
  938. cli compose validate --verbose
  939. # Skip semantic validation (only Jinja2)
  940. cli compose validate --no-semantic
  941. """
  942. from .validators import get_validator_registry
  943. # Validate from path takes precedence
  944. if path:
  945. try:
  946. template_path = Path(path).resolve()
  947. if not template_path.exists():
  948. self.display.display_error(f"Path does not exist: {path}")
  949. raise Exit(code=1)
  950. if not template_path.is_dir():
  951. self.display.display_error(f"Path is not a directory: {path}")
  952. raise Exit(code=1)
  953. console.print(
  954. f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]\n"
  955. )
  956. template = Template(template_path, library_name="local")
  957. template_id = template.id
  958. except Exception as e:
  959. self.display.display_error(
  960. f"Failed to load template from path '{path}': {e}"
  961. )
  962. raise Exit(code=1)
  963. elif template_id:
  964. # Validate a specific template by ID
  965. try:
  966. template = self._load_template_by_id(template_id)
  967. console.print(
  968. f"[bold]Validating template:[/bold] [cyan]{template_id}[/cyan]\n"
  969. )
  970. except Exception as e:
  971. self.display.display_error(
  972. f"Failed to load template '{template_id}': {e}"
  973. )
  974. raise Exit(code=1)
  975. else:
  976. # Validate all templates - handled separately below
  977. template = None
  978. # Single template validation
  979. if template:
  980. try:
  981. # Trigger validation by accessing used_variables
  982. _ = template.used_variables
  983. # Trigger variable definition validation by accessing variables
  984. _ = template.variables
  985. self.display.display_success("Jinja2 validation passed")
  986. # Semantic validation
  987. if semantic:
  988. console.print(
  989. "\n[bold cyan]Running semantic validation...[/bold cyan]"
  990. )
  991. registry = get_validator_registry()
  992. has_semantic_errors = False
  993. # Render template with default values for validation
  994. debug_mode = logger.isEnabledFor(logging.DEBUG)
  995. rendered_files, _ = template.render(
  996. template.variables, debug=debug_mode
  997. )
  998. for file_path, content in rendered_files.items():
  999. result = registry.validate_file(content, file_path)
  1000. if (
  1001. result.errors
  1002. or result.warnings
  1003. or (verbose and result.info)
  1004. ):
  1005. console.print(f"\n[cyan]File:[/cyan] {file_path}")
  1006. result.display(f"{file_path}")
  1007. if result.errors:
  1008. has_semantic_errors = True
  1009. if not has_semantic_errors:
  1010. self.display.display_success("Semantic validation passed")
  1011. else:
  1012. self.display.display_error("Semantic validation found errors")
  1013. raise Exit(code=1)
  1014. if verbose:
  1015. console.print(
  1016. f"\n[dim]Template path: {template.template_dir}[/dim]"
  1017. )
  1018. console.print(
  1019. f"[dim]Found {len(template.used_variables)} variables[/dim]"
  1020. )
  1021. if semantic:
  1022. console.print(
  1023. f"[dim]Generated {len(rendered_files)} files[/dim]"
  1024. )
  1025. except TemplateRenderError as e:
  1026. # Display enhanced error information for template rendering errors
  1027. self.display.display_template_render_error(
  1028. e, context=f"template '{template_id}'"
  1029. )
  1030. raise Exit(code=1)
  1031. except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
  1032. self.display.display_error(f"Validation failed for '{template_id}':")
  1033. console.print(f"\n{e}")
  1034. raise Exit(code=1)
  1035. except Exception as e:
  1036. self.display.display_error(
  1037. f"Unexpected error validating '{template_id}': {e}"
  1038. )
  1039. raise Exit(code=1)
  1040. return
  1041. else:
  1042. # Validate all templates
  1043. console.print(f"[bold]Validating all {self.name} templates...[/bold]\n")
  1044. valid_count = 0
  1045. invalid_count = 0
  1046. errors = []
  1047. # Use centralized helper to load all templates
  1048. # Note: Exceptions during load are already logged by _load_all_templates
  1049. all_templates = self._load_all_templates()
  1050. total = len(all_templates)
  1051. for template in all_templates:
  1052. try:
  1053. # Trigger validation
  1054. _ = template.used_variables
  1055. _ = template.variables
  1056. valid_count += 1
  1057. if verbose:
  1058. self.display.display_success(template.id)
  1059. except ValueError as e:
  1060. invalid_count += 1
  1061. errors.append((template.id, str(e)))
  1062. if verbose:
  1063. self.display.display_error(template.id)
  1064. except Exception as e:
  1065. invalid_count += 1
  1066. errors.append((template.id, f"Load error: {e}"))
  1067. if verbose:
  1068. self.display.display_warning(template.id)
  1069. # Summary
  1070. summary_items = {
  1071. "Total templates:": str(total),
  1072. "[green]Valid:[/green]": str(valid_count),
  1073. "[red]Invalid:[/red]": str(invalid_count),
  1074. }
  1075. self.display.display_summary_table("Validation Summary", summary_items)
  1076. # Show errors if any
  1077. if errors:
  1078. console.print("\n[bold red]Validation Errors:[/bold red]")
  1079. for template_id, error_msg in errors:
  1080. console.print(
  1081. f"\n[yellow]Template:[/yellow] [cyan]{template_id}[/cyan]"
  1082. )
  1083. console.print(f"[dim]{error_msg}[/dim]")
  1084. raise Exit(code=1)
  1085. else:
  1086. self.display.display_success("All templates are valid!")
  1087. @classmethod
  1088. def register_cli(cls, app: Typer) -> None:
  1089. """Register module commands with the main app."""
  1090. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  1091. module_instance = cls()
  1092. module_app = Typer(help=cls.description)
  1093. module_app.command("list")(module_instance.list)
  1094. module_app.command("search")(module_instance.search)
  1095. module_app.command("show")(module_instance.show)
  1096. module_app.command("validate")(module_instance.validate)
  1097. module_app.command(
  1098. "generate",
  1099. context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
  1100. )(module_instance.generate)
  1101. # Add defaults commands (simplified - only manage default values)
  1102. defaults_app = Typer(help="Manage default values for template variables")
  1103. defaults_app.command("get", help="Get default value(s)")(
  1104. module_instance.config_get
  1105. )
  1106. defaults_app.command("set", help="Set a default value")(
  1107. module_instance.config_set
  1108. )
  1109. defaults_app.command("rm", help="Remove a specific default value")(
  1110. module_instance.config_remove
  1111. )
  1112. defaults_app.command("clear", help="Clear default value(s)")(
  1113. module_instance.config_clear
  1114. )
  1115. defaults_app.command(
  1116. "list", help="Display the config for this module in YAML format"
  1117. )(module_instance.config_list)
  1118. module_app.add_typer(defaults_app, name="defaults")
  1119. app.add_typer(module_app, name=cls.name, help=cls.description)
  1120. logger.info(f"Module '{cls.name}' CLI commands registered")
  1121. def _load_template_by_id(self, id: str) -> Template:
  1122. """Load a template by its ID, supporting qualified IDs.
  1123. Supports both formats:
  1124. - Simple: "alloy" (uses priority system)
  1125. - Qualified: "alloy.default" (loads from specific library)
  1126. Args:
  1127. id: Template ID (simple or qualified)
  1128. Returns:
  1129. Template instance
  1130. Raises:
  1131. FileNotFoundError: If template is not found
  1132. """
  1133. logger.debug(f"Loading template with ID '{id}' from module '{self.name}'")
  1134. # find_by_id now handles both simple and qualified IDs
  1135. result = self.libraries.find_by_id(self.name, id)
  1136. if not result:
  1137. raise FileNotFoundError(
  1138. f"Template '{id}' not found in module '{self.name}'"
  1139. )
  1140. template_dir, library_name = result
  1141. # Get library type
  1142. library = next(
  1143. (lib for lib in self.libraries.libraries if lib.name == library_name), None
  1144. )
  1145. library_type = library.library_type if library else "git"
  1146. try:
  1147. template = Template(
  1148. template_dir, library_name=library_name, library_type=library_type
  1149. )
  1150. # Validate schema version compatibility
  1151. template._validate_schema_version(self.schema_version, self.name)
  1152. # If the original ID was qualified, preserve it
  1153. if "." in id:
  1154. template.id = id
  1155. return template
  1156. except Exception as exc:
  1157. logger.error(f"Failed to load template '{id}': {exc}")
  1158. raise FileNotFoundError(
  1159. f"Template '{id}' could not be loaded: {exc}"
  1160. ) from exc