base_commands.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. """Base commands for module: list, search, show, validate, generate."""
  2. from __future__ import annotations
  3. import logging
  4. from dataclasses import dataclass
  5. from pathlib import Path
  6. from jinja2 import Template as Jinja2Template
  7. from typer import Exit
  8. from ..config import ConfigManager
  9. from ..display import DisplayManager, IconManager
  10. from ..exceptions import (
  11. TemplateRenderError,
  12. TemplateSyntaxError,
  13. TemplateValidationError,
  14. )
  15. from ..input import InputManager
  16. from ..template import (
  17. TEMPLATE_STATUS_DRAFT,
  18. TEMPLATE_STATUS_PUBLISHED,
  19. Template,
  20. )
  21. from ..validators import get_validator_registry
  22. from .helpers import (
  23. apply_cli_overrides,
  24. apply_var_file,
  25. apply_variable_defaults,
  26. collect_variable_values,
  27. )
  28. logger = logging.getLogger(__name__)
  29. # File size thresholds for display formatting
  30. BYTES_PER_KB = 1024
  31. BYTES_PER_MB = 1024 * 1024
  32. @dataclass
  33. class GenerationConfig:
  34. """Configuration for template generation."""
  35. id: str
  36. directory: str | None = None
  37. output: str | None = None
  38. interactive: bool = True
  39. var: list[str] | None = None
  40. var_file: str | None = None
  41. dry_run: bool = False
  42. show_files: bool = False
  43. quiet: bool = False
  44. @dataclass
  45. class ConfirmationContext:
  46. """Context for file generation confirmation."""
  47. output_dir: Path
  48. rendered_files: dict[str, str]
  49. existing_files: list[Path] | None
  50. dir_not_empty: bool
  51. dry_run: bool
  52. interactive: bool
  53. display: DisplayManager
  54. def list_templates(module_instance, raw: bool = False) -> list:
  55. """List all templates."""
  56. logger.debug(f"Listing templates for module '{module_instance.name}'")
  57. # Load all templates using centralized helper
  58. filtered_templates = module_instance._load_all_templates()
  59. if filtered_templates:
  60. if raw:
  61. # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
  62. # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
  63. for template in filtered_templates:
  64. tags_list = template.metadata.tags or []
  65. ",".join(tags_list) if tags_list else "-"
  66. (str(template.metadata.version) if template.metadata.version else "-")
  67. else:
  68. # Output rich table format
  69. def format_template_row(template):
  70. name = template.metadata.name or "Unnamed Template"
  71. tags_list = template.metadata.tags or []
  72. tags = ", ".join(tags_list) if tags_list else "-"
  73. version = str(template.metadata.version) if template.metadata.version else ""
  74. # Get status and format it
  75. status = template.status
  76. if status == TEMPLATE_STATUS_PUBLISHED:
  77. status_display = "[green]Published[/green]"
  78. elif status == TEMPLATE_STATUS_DRAFT:
  79. status_display = "[dim]Draft[/dim]"
  80. else: # TEMPLATE_STATUS_INVALID
  81. status_display = "[red]Invalid[/red]"
  82. schema = template.schema_version if hasattr(template, "schema_version") else "1.0"
  83. library_name = template.metadata.library or ""
  84. library_type = template.metadata.library_type or "git"
  85. # Format library with icon and color
  86. icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
  87. color = "yellow" if library_type == "static" else "blue"
  88. library_display = f"[{color}]{icon} {library_name}[/{color}]"
  89. # Apply dimmed style to entire row if draft
  90. if status == TEMPLATE_STATUS_DRAFT:
  91. template_id = f"[dim]{template.id}[/dim]"
  92. name = f"[dim]{name}[/dim]"
  93. tags = f"[dim]{tags}[/dim]"
  94. version = f"[dim]{version}[/dim]"
  95. schema = f"[dim]{schema}[/dim]"
  96. library_display = f"[dim]{icon} {library_name}[/dim]"
  97. else:
  98. template_id = template.id
  99. return (template_id, name, tags, version, status_display, schema, library_display)
  100. module_instance.display.data_table(
  101. columns=[
  102. {"name": "ID", "style": "bold", "no_wrap": True},
  103. {"name": "Name"},
  104. {"name": "Tags"},
  105. {"name": "Version", "no_wrap": True},
  106. {"name": "Status", "no_wrap": True},
  107. {"name": "Schema", "no_wrap": True},
  108. {"name": "Library", "no_wrap": True},
  109. ],
  110. rows=filtered_templates,
  111. row_formatter=format_template_row,
  112. )
  113. else:
  114. logger.info(f"No templates found for module '{module_instance.name}'")
  115. module_instance.display.info(
  116. f"No templates found for module '{module_instance.name}'",
  117. context="Use 'bp repo update' to update libraries or check library configuration",
  118. )
  119. return filtered_templates
  120. def search_templates(module_instance, query: str) -> list:
  121. """Search for templates by ID containing the search string."""
  122. logger.debug(f"Searching templates for module '{module_instance.name}' with query='{query}'")
  123. # Load templates with search filter using centralized helper
  124. filtered_templates = module_instance._load_all_templates(lambda t: query.lower() in t.id.lower())
  125. if filtered_templates:
  126. logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{module_instance.name}'")
  127. def format_template_row(template):
  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 = str(template.metadata.version) if template.metadata.version else ""
  132. # Get status and format it
  133. status = template.status
  134. if status == TEMPLATE_STATUS_PUBLISHED:
  135. status_display = "[green]Published[/green]"
  136. elif status == TEMPLATE_STATUS_DRAFT:
  137. status_display = "[dim]Draft[/dim]"
  138. else: # TEMPLATE_STATUS_INVALID
  139. status_display = "[red]Invalid[/red]"
  140. schema = template.schema_version if hasattr(template, "schema_version") else "1.0"
  141. library_name = template.metadata.library or ""
  142. library_type = template.metadata.library_type or "git"
  143. # Format library with icon and color
  144. icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
  145. color = "yellow" if library_type == "static" else "blue"
  146. library_display = f"[{color}]{icon} {library_name}[/{color}]"
  147. # Apply dimmed style to entire row if draft
  148. if status == TEMPLATE_STATUS_DRAFT:
  149. template_id = f"[dim]{template.id}[/dim]"
  150. name = f"[dim]{name}[/dim]"
  151. tags = f"[dim]{tags}[/dim]"
  152. version = f"[dim]{version}[/dim]"
  153. schema = f"[dim]{schema}[/dim]"
  154. library_display = f"[dim]{icon} {library_name}[/dim]"
  155. else:
  156. template_id = template.id
  157. return (template_id, name, tags, version, status_display, schema, library_display)
  158. module_instance.display.data_table(
  159. columns=[
  160. {"name": "ID", "style": "bold", "no_wrap": True},
  161. {"name": "Name"},
  162. {"name": "Tags"},
  163. {"name": "Version", "no_wrap": True},
  164. {"name": "Status", "no_wrap": True},
  165. {"name": "Schema", "no_wrap": True},
  166. {"name": "Library", "no_wrap": True},
  167. ],
  168. rows=filtered_templates,
  169. row_formatter=format_template_row,
  170. )
  171. else:
  172. logger.info(f"No templates found matching '{query}' for module '{module_instance.name}'")
  173. module_instance.display.warning(
  174. f"No templates found matching '{query}'",
  175. context=f"module '{module_instance.name}'",
  176. )
  177. return filtered_templates
  178. def show_template(module_instance, id: str, var: list[str] | None = None, var_file: str | None = None) -> None:
  179. """Show template details with optional variable overrides."""
  180. logger.debug(f"Showing template '{id}' from module '{module_instance.name}'")
  181. template = module_instance._load_template_by_id(id)
  182. if not template:
  183. module_instance.display.error(f"Template '{id}' not found", context=f"module '{module_instance.name}'")
  184. return
  185. # Apply defaults and overrides (same precedence as generate command)
  186. if template.variables:
  187. config = ConfigManager()
  188. apply_variable_defaults(template, config, module_instance.name)
  189. apply_var_file(template, var_file, module_instance.display)
  190. apply_cli_overrides(template, var)
  191. # Re-sort sections after applying overrides (toggle values may have changed)
  192. template.variables.sort_sections()
  193. # Reset disabled bool variables to False to prevent confusion
  194. reset_vars = template.variables.reset_disabled_bool_variables()
  195. if reset_vars:
  196. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  197. # Display template header
  198. module_instance.display.templates.render_template_header(template, id)
  199. # Display file tree
  200. module_instance.display.templates.render_file_tree(template)
  201. # Display variables table
  202. module_instance.display.variables.render_variables_table(template)
  203. def check_output_directory(
  204. output_dir: Path,
  205. rendered_files: dict[str, str],
  206. interactive: bool,
  207. display: DisplayManager,
  208. ) -> list[Path] | None:
  209. """Check output directory for conflicts and get user confirmation if needed."""
  210. dir_exists = output_dir.exists()
  211. dir_not_empty = dir_exists and any(output_dir.iterdir())
  212. # Check which files already exist
  213. existing_files = []
  214. if dir_exists:
  215. for file_path in rendered_files:
  216. full_path = output_dir / file_path
  217. if full_path.exists():
  218. existing_files.append(full_path)
  219. # Warn if directory is not empty
  220. if dir_not_empty:
  221. if interactive:
  222. display.text("") # Add newline before warning
  223. # Combine directory warning and file count on same line
  224. warning_msg = f"Directory '{output_dir}' is not empty."
  225. if existing_files:
  226. warning_msg += f" {len(existing_files)} file(s) will be overwritten."
  227. display.warning(warning_msg)
  228. display.text("") # Add newline after warning
  229. input_mgr = InputManager()
  230. if not input_mgr.confirm("Continue?", default=False):
  231. display.info("Generation cancelled")
  232. return None
  233. else:
  234. # Non-interactive mode: show warning but continue
  235. logger.warning(f"Directory '{output_dir}' is not empty")
  236. if existing_files:
  237. logger.warning(f"{len(existing_files)} file(s) will be overwritten")
  238. return existing_files
  239. def get_generation_confirmation(_ctx: ConfirmationContext) -> bool:
  240. """Display file generation confirmation and get user approval."""
  241. # No confirmation needed - either non-interactive, dry-run, or already confirmed during directory check
  242. return True
  243. def _collect_subdirectories(rendered_files: dict[str, str]) -> set[Path]:
  244. """Collect unique subdirectories from file paths."""
  245. subdirs = set()
  246. for file_path in rendered_files:
  247. parts = Path(file_path).parts
  248. for i in range(1, len(parts)):
  249. subdirs.add(Path(*parts[:i]))
  250. return subdirs
  251. def _analyze_file_operations(
  252. output_dir: Path, rendered_files: dict[str, str]
  253. ) -> tuple[list[tuple[str, int, str]], int, int, int]:
  254. """Analyze file operations and return statistics."""
  255. total_size = 0
  256. new_files = 0
  257. overwrite_files = 0
  258. file_operations = []
  259. for file_path, content in sorted(rendered_files.items()):
  260. full_path = output_dir / file_path
  261. file_size = len(content.encode("utf-8"))
  262. total_size += file_size
  263. if full_path.exists():
  264. status = "Overwrite"
  265. overwrite_files += 1
  266. else:
  267. status = "Create"
  268. new_files += 1
  269. file_operations.append((file_path, file_size, status))
  270. return file_operations, total_size, new_files, overwrite_files
  271. def _format_size(total_size: int) -> str:
  272. """Format byte size into human-readable string."""
  273. if total_size < BYTES_PER_KB:
  274. return f"{total_size}B"
  275. if total_size < BYTES_PER_MB:
  276. return f"{total_size / BYTES_PER_KB:.1f}KB"
  277. return f"{total_size / BYTES_PER_MB:.1f}MB"
  278. def execute_dry_run(
  279. id: str,
  280. output_dir: Path,
  281. rendered_files: dict[str, str],
  282. show_files: bool,
  283. display: DisplayManager,
  284. ) -> tuple[int, int, str]:
  285. """Execute dry run mode - preview files without writing.
  286. Returns:
  287. Tuple of (total_files, overwrite_files, size_str) for summary display
  288. """
  289. _file_operations, total_size, _new_files, overwrite_files = _analyze_file_operations(output_dir, rendered_files)
  290. size_str = _format_size(total_size)
  291. # Show file contents if requested
  292. if show_files:
  293. display.text("")
  294. display.heading("File Contents")
  295. for file_path, content in sorted(rendered_files.items()):
  296. display.text(f"\n[cyan]{file_path}[/cyan]")
  297. display.text(f"{'─' * 80}")
  298. display.text(content)
  299. display.text("")
  300. logger.info(f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes")
  301. return len(rendered_files), overwrite_files, size_str
  302. def write_rendered_files(
  303. output_dir: Path,
  304. rendered_files: dict[str, str],
  305. _quiet: bool,
  306. _display: DisplayManager,
  307. ) -> None:
  308. """Write rendered files to the output directory."""
  309. output_dir.mkdir(parents=True, exist_ok=True)
  310. for file_path, content in rendered_files.items():
  311. full_path = output_dir / file_path
  312. full_path.parent.mkdir(parents=True, exist_ok=True)
  313. with full_path.open("w", encoding="utf-8") as f:
  314. f.write(content)
  315. logger.info(f"Template written to directory: {output_dir}")
  316. def _prepare_template(
  317. module_instance,
  318. id: str,
  319. var_file: str | None,
  320. var: list[str] | None,
  321. display: DisplayManager,
  322. ):
  323. """Load template and apply all defaults/overrides."""
  324. template = module_instance._load_template_by_id(id)
  325. config = ConfigManager()
  326. apply_variable_defaults(template, config, module_instance.name)
  327. apply_var_file(template, var_file, display)
  328. apply_cli_overrides(template, var)
  329. if template.variables:
  330. template.variables.sort_sections()
  331. reset_vars = template.variables.reset_disabled_bool_variables()
  332. if reset_vars:
  333. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  334. return template
  335. def _render_template(template, id: str, display: DisplayManager, interactive: bool):
  336. """Validate, render template and collect variable values."""
  337. variable_values = collect_variable_values(template, interactive)
  338. if template.variables:
  339. template.variables.validate_all()
  340. debug_mode = logger.isEnabledFor(logging.DEBUG)
  341. rendered_files, variable_values = template.render(template.variables, debug=debug_mode)
  342. if not rendered_files:
  343. display.error(
  344. "Template rendering returned no files",
  345. context="template generation",
  346. )
  347. raise Exit(code=1)
  348. logger.info(f"Successfully rendered template '{id}'")
  349. return rendered_files, variable_values
  350. def _determine_output_dir(directory: str | None, output: str | None, id: str) -> tuple[Path, bool]:
  351. """Determine and normalize output directory path.
  352. Returns:
  353. Tuple of (output_dir, used_deprecated_arg) where used_deprecated_arg indicates
  354. if the deprecated positional directory argument was used.
  355. """
  356. used_deprecated_arg = False
  357. # Priority: --output flag > positional directory argument > template ID
  358. if output:
  359. output_dir = Path(output)
  360. elif directory:
  361. output_dir = Path(directory)
  362. used_deprecated_arg = True
  363. logger.debug(f"Using deprecated positional directory argument: {directory}")
  364. else:
  365. output_dir = Path(id)
  366. # Normalize paths that look like absolute paths but are relative
  367. if not output_dir.is_absolute() and str(output_dir).startswith(("Users/", "home/", "usr/", "opt/", "var/", "tmp/")):
  368. output_dir = Path("/") / output_dir
  369. logger.debug(f"Normalized relative-looking absolute path to: {output_dir}")
  370. return output_dir, used_deprecated_arg
  371. def _display_template_error(display: DisplayManager, template_id: str, error: TemplateRenderError) -> None:
  372. """Display template rendering error with clean formatting."""
  373. display.text("")
  374. display.text("─" * 80, style="dim")
  375. display.text("")
  376. # Build details if available
  377. details = None
  378. if error.file_path:
  379. details = error.file_path
  380. if error.line_number:
  381. details += f":line {error.line_number}"
  382. # Display error with details
  383. display.error(f"Failed to generate boilerplate from template '{template_id}'", details=details)
  384. def _display_generic_error(display: DisplayManager, template_id: str, error: Exception) -> None:
  385. """Display generic error with clean formatting."""
  386. display.text("")
  387. display.text("─" * 80, style="dim")
  388. display.text("")
  389. # Truncate long error messages
  390. max_error_length = 100
  391. error_msg = str(error)
  392. if len(error_msg) > max_error_length:
  393. error_msg = f"{error_msg[:max_error_length]}..."
  394. # Display error with details
  395. display.error(f"Failed to generate boilerplate from template '{template_id}'", details=error_msg)
  396. def generate_template(module_instance, config: GenerationConfig) -> None: # noqa: PLR0912, PLR0915
  397. """Generate from template."""
  398. logger.info(f"Starting generation for template '{config.id}' from module '{module_instance.name}'")
  399. display = DisplayManager(quiet=config.quiet) if config.quiet else module_instance.display
  400. template = _prepare_template(module_instance, config.id, config.var_file, config.var, display)
  401. # Determine output directory early to check for deprecated argument usage
  402. output_dir, used_deprecated_arg = _determine_output_dir(config.directory, config.output, config.id)
  403. if not config.quiet:
  404. # Display template header
  405. module_instance.display.templates.render_template_header(template, config.id)
  406. # Display file tree
  407. module_instance.display.templates.render_file_tree(template)
  408. # Display variables table
  409. module_instance.display.variables.render_variables_table(template)
  410. module_instance.display.text("")
  411. # Show deprecation warning BEFORE any user interaction
  412. if used_deprecated_arg:
  413. module_instance.display.warning(
  414. "Using positional argument for output directory is deprecated and will be removed in v0.2.0",
  415. details="Use --output/-o flag instead",
  416. )
  417. module_instance.display.text("")
  418. try:
  419. rendered_files, variable_values = _render_template(template, config.id, display, config.interactive)
  420. # Check for conflicts and get confirmation (skip in quiet mode)
  421. if not config.quiet:
  422. existing_files = check_output_directory(output_dir, rendered_files, config.interactive, display)
  423. if existing_files is None:
  424. return # User cancelled
  425. dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
  426. ctx = ConfirmationContext(
  427. output_dir=output_dir,
  428. rendered_files=rendered_files,
  429. existing_files=existing_files,
  430. dir_not_empty=dir_not_empty,
  431. dry_run=config.dry_run,
  432. interactive=config.interactive,
  433. display=display,
  434. )
  435. if not get_generation_confirmation(ctx):
  436. return # User cancelled
  437. # Execute generation (dry run or actual)
  438. dry_run_stats = None
  439. if config.dry_run:
  440. if not config.quiet:
  441. dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
  442. else:
  443. write_rendered_files(output_dir, rendered_files, config.quiet, display)
  444. # Display next steps (not in quiet mode)
  445. if template.metadata.next_steps and not config.quiet:
  446. display.text("")
  447. display.heading("Next Steps")
  448. try:
  449. next_steps_template = Jinja2Template(template.metadata.next_steps)
  450. rendered_next_steps = next_steps_template.render(variable_values)
  451. display.status.markdown(rendered_next_steps)
  452. except Exception as e:
  453. logger.warning(f"Failed to render next_steps as template: {e}")
  454. # Fallback to plain text if rendering fails
  455. display.status.markdown(template.metadata.next_steps)
  456. # Display final status message at the end
  457. if not config.quiet:
  458. display.text("")
  459. display.text("─" * 80, style="dim")
  460. if config.dry_run and dry_run_stats:
  461. total_files, overwrite_files, size_str = dry_run_stats
  462. if overwrite_files > 0:
  463. display.warning(
  464. f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}' "
  465. f"({overwrite_files} would be overwritten)"
  466. )
  467. else:
  468. display.success(
  469. f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}'"
  470. )
  471. else:
  472. # Actual generation completed
  473. display.success(f"Boilerplate generated successfully in '{output_dir}'")
  474. except TemplateRenderError as e:
  475. _display_template_error(display, config.id, e)
  476. raise Exit(code=1) from None
  477. except Exception as e:
  478. _display_generic_error(display, config.id, e)
  479. raise Exit(code=1) from None
  480. def validate_templates(
  481. module_instance,
  482. template_id: str,
  483. path: str | None,
  484. verbose: bool,
  485. semantic: bool,
  486. ) -> None:
  487. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
  488. # Load template based on input
  489. template = _load_template_for_validation(module_instance, template_id, path)
  490. if template:
  491. _validate_single_template(module_instance, template, template_id, verbose, semantic)
  492. else:
  493. _validate_all_templates(module_instance, verbose)
  494. def _load_template_for_validation(module_instance, template_id: str, path: str | None):
  495. """Load a template from path or ID for validation."""
  496. if path:
  497. template_path = Path(path).resolve()
  498. if not template_path.exists():
  499. module_instance.display.error(f"Path does not exist: {path}")
  500. raise Exit(code=1) from None
  501. if not template_path.is_dir():
  502. module_instance.display.error(f"Path is not a directory: {path}")
  503. raise Exit(code=1) from None
  504. module_instance.display.info(f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]")
  505. try:
  506. return Template(template_path, library_name="local")
  507. except Exception as e:
  508. module_instance.display.error(f"Failed to load template from path '{path}': {e}")
  509. raise Exit(code=1) from None
  510. if template_id:
  511. try:
  512. template = module_instance._load_template_by_id(template_id)
  513. module_instance.display.info(f"Validating template: {template_id}")
  514. return template
  515. except Exception as e:
  516. module_instance.display.error(f"Failed to load template '{template_id}': {e}")
  517. raise Exit(code=1) from None
  518. return None
  519. def _validate_single_template(module_instance, template, template_id: str, verbose: bool, semantic: bool) -> None:
  520. """Validate a single template."""
  521. try:
  522. # Jinja2 validation
  523. _ = template.used_variables
  524. _ = template.variables
  525. module_instance.display.success("Jinja2 validation passed")
  526. # Semantic validation
  527. if semantic:
  528. _run_semantic_validation(module_instance, template, verbose)
  529. # Verbose output
  530. if verbose:
  531. _display_validation_details(module_instance, template, semantic)
  532. except TemplateRenderError as e:
  533. module_instance.display.error(str(e), context=f"template '{template_id}'")
  534. raise Exit(code=1) from None
  535. except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
  536. module_instance.display.error(f"Validation failed for '{template_id}':")
  537. module_instance.display.info(f"\n{e}")
  538. raise Exit(code=1) from None
  539. except Exception as e:
  540. module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
  541. raise Exit(code=1) from None
  542. def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
  543. """Run semantic validation on rendered template files."""
  544. module_instance.display.info("")
  545. module_instance.display.info("Running semantic validation...")
  546. registry = get_validator_registry()
  547. debug_mode = logger.isEnabledFor(logging.DEBUG)
  548. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  549. has_semantic_errors = False
  550. for file_path, content in rendered_files.items():
  551. result = registry.validate_file(content, file_path)
  552. if result.errors or result.warnings or (verbose and result.info):
  553. module_instance.display.info(f"\nFile: {file_path}")
  554. result.display(f"{file_path}")
  555. if result.errors:
  556. has_semantic_errors = True
  557. if has_semantic_errors:
  558. module_instance.display.error("Semantic validation found errors")
  559. raise Exit(code=1) from None
  560. module_instance.display.success("Semantic validation passed")
  561. def _display_validation_details(module_instance, template, semantic: bool) -> None:
  562. """Display verbose validation details."""
  563. module_instance.display.info(f"\nTemplate path: {template.template_dir}")
  564. module_instance.display.info(f"Found {len(template.used_variables)} variables")
  565. if semantic:
  566. debug_mode = logger.isEnabledFor(logging.DEBUG)
  567. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  568. module_instance.display.info(f"Generated {len(rendered_files)} files")
  569. def _validate_all_templates(module_instance, verbose: bool) -> None:
  570. """Validate all templates in the module."""
  571. module_instance.display.info(f"Validating all {module_instance.name} templates...")
  572. valid_count = 0
  573. invalid_count = 0
  574. errors = []
  575. all_templates = module_instance._load_all_templates()
  576. total = len(all_templates)
  577. for template in all_templates:
  578. try:
  579. _ = template.used_variables
  580. _ = template.variables
  581. valid_count += 1
  582. if verbose:
  583. module_instance.display.success(template.id)
  584. except ValueError as e:
  585. invalid_count += 1
  586. errors.append((template.id, str(e)))
  587. if verbose:
  588. module_instance.display.error(template.id)
  589. except Exception as e:
  590. invalid_count += 1
  591. errors.append((template.id, f"Load error: {e}"))
  592. if verbose:
  593. module_instance.display.warning(template.id)
  594. # Display summary
  595. module_instance.display.info("")
  596. module_instance.display.info(f"Total templates: {total}")
  597. module_instance.display.info(f"Valid: {valid_count}")
  598. module_instance.display.info(f"Invalid: {invalid_count}")
  599. if errors:
  600. module_instance.display.info("")
  601. for template_id, error_msg in errors:
  602. module_instance.display.error(f"{template_id}: {error_msg}")
  603. raise Exit(code=1)
  604. if total > 0:
  605. module_instance.display.info("")
  606. module_instance.display.success("All templates are valid")