base_commands.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  1. """Base commands for module: list, search, show, validate, generate."""
  2. from __future__ import annotations
  3. import logging
  4. from dataclasses import dataclass, replace
  5. from pathlib import Path
  6. from typer import Exit
  7. from ..config import ConfigManager
  8. from ..display import DisplayManager, IconManager
  9. from ..exceptions import (
  10. TemplateRenderError,
  11. TemplateSyntaxError,
  12. TemplateValidationError,
  13. )
  14. from ..input import InputManager
  15. from ..template import Template
  16. from ..validation import DependencyMatrixBuilder, MatrixOptions, ValidationRunner
  17. from ..validators import get_validator_registry
  18. from .generation_destination import (
  19. GenerationDestination,
  20. format_remote_destination,
  21. prompt_generation_destination,
  22. resolve_cli_destination,
  23. write_rendered_files_remote,
  24. )
  25. from .helpers import (
  26. apply_cli_overrides,
  27. apply_var_file,
  28. apply_variable_defaults,
  29. collect_variable_values,
  30. )
  31. logger = logging.getLogger(__name__)
  32. # File size thresholds for display formatting
  33. BYTES_PER_KB = 1024
  34. BYTES_PER_MB = 1024 * 1024
  35. @dataclass
  36. class GenerationConfig:
  37. """Configuration for template generation."""
  38. id: str
  39. output: str | None = None
  40. remote: str | None = None
  41. remote_path: str | None = None
  42. interactive: bool = True
  43. var: list[str] | None = None
  44. var_file: str | None = None
  45. dry_run: bool = False
  46. show_files: bool = False
  47. quiet: bool = False
  48. name: str | None = None
  49. @dataclass
  50. class ValidationConfig:
  51. """Configuration for template validation."""
  52. verbose: bool
  53. semantic: bool = True
  54. matrix: bool = False
  55. kind: bool = False
  56. all_templates: bool = False
  57. matrix_max_combinations: int = 100
  58. kind_validator: object | None = None
  59. quiet_success: bool = False
  60. def list_templates(module_instance, raw: bool = False) -> list:
  61. """List all templates."""
  62. logger.debug(f"Listing templates for module '{module_instance.name}'")
  63. # Load all templates using centralized helper
  64. filtered_templates = module_instance._load_all_templates()
  65. if filtered_templates:
  66. if raw:
  67. # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
  68. # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
  69. for template in filtered_templates:
  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 = template.metadata.version.name if template.metadata.version else "-"
  74. library = template.metadata.library or "-"
  75. module_instance.display.text("\t".join([template.id, name, tags, version, library]))
  76. else:
  77. # Output rich table format
  78. def format_template_row(template):
  79. name = template.metadata.name or "Unnamed Template"
  80. tags_list = template.metadata.tags or []
  81. tags = ", ".join(tags_list) if tags_list else "-"
  82. version = template.metadata.version.name if template.metadata.version else ""
  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. return (template.id, name, tags, version, library_display)
  90. module_instance.display.data_table(
  91. columns=[
  92. {"name": "ID", "style": "bold", "no_wrap": True},
  93. {"name": "Name"},
  94. {"name": "Tags"},
  95. {"name": "Version", "no_wrap": True},
  96. {"name": "Library", "no_wrap": True},
  97. ],
  98. rows=filtered_templates,
  99. row_formatter=format_template_row,
  100. )
  101. else:
  102. logger.info(f"No templates found for module '{module_instance.name}'")
  103. module_instance.display.info(
  104. f"No templates found for module '{module_instance.name}'",
  105. context="Use 'bp repo update' to update libraries or check library configuration",
  106. )
  107. return filtered_templates
  108. def search_templates(module_instance, query: str) -> list:
  109. """Search for templates by ID containing the search string."""
  110. logger.debug(f"Searching templates for module '{module_instance.name}' with query='{query}'")
  111. # Load templates with search filter using centralized helper
  112. filtered_templates = module_instance._load_all_templates(lambda t: query.lower() in t.id.lower())
  113. if filtered_templates:
  114. logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{module_instance.name}'")
  115. def format_template_row(template):
  116. name = template.metadata.name or "Unnamed Template"
  117. tags_list = template.metadata.tags or []
  118. tags = ", ".join(tags_list) if tags_list else "-"
  119. version = template.metadata.version.name if template.metadata.version else ""
  120. library_name = template.metadata.library or ""
  121. library_type = template.metadata.library_type or "git"
  122. # Format library with icon and color
  123. icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
  124. color = "yellow" if library_type == "static" else "blue"
  125. library_display = f"[{color}]{icon} {library_name}[/{color}]"
  126. return (template.id, name, tags, version, library_display)
  127. module_instance.display.data_table(
  128. columns=[
  129. {"name": "ID", "style": "bold", "no_wrap": True},
  130. {"name": "Name"},
  131. {"name": "Tags"},
  132. {"name": "Version", "no_wrap": True},
  133. {"name": "Library", "no_wrap": True},
  134. ],
  135. rows=filtered_templates,
  136. row_formatter=format_template_row,
  137. )
  138. else:
  139. logger.info(f"No templates found matching '{query}' for module '{module_instance.name}'")
  140. module_instance.display.warning(
  141. f"No templates found matching '{query}'",
  142. context=f"module '{module_instance.name}'",
  143. )
  144. return filtered_templates
  145. def show_template(module_instance, id: str, var: list[str] | None = None, var_file: str | None = None) -> None:
  146. """Show template details with optional variable overrides."""
  147. logger.debug(f"Showing template '{id}' from module '{module_instance.name}'")
  148. template = module_instance._load_template_by_id(id)
  149. if not template:
  150. module_instance.display.error(f"Template '{id}' not found", context=f"module '{module_instance.name}'")
  151. return
  152. # Apply defaults and overrides (same precedence as generate command)
  153. if template.variables:
  154. config = ConfigManager()
  155. apply_variable_defaults(template, config, module_instance.name)
  156. apply_var_file(template, var_file, module_instance.display)
  157. apply_cli_overrides(template, var)
  158. # Re-sort sections after applying overrides (toggle values may have changed)
  159. template.variables.sort_sections()
  160. # Reset disabled bool variables to False to prevent confusion
  161. reset_vars = template.variables.reset_disabled_bool_variables()
  162. if reset_vars:
  163. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  164. # Display template header
  165. module_instance.display.templates.render_template_header(template, id)
  166. # Display file tree
  167. module_instance.display.templates.render_file_tree(template)
  168. # Display variables table
  169. module_instance.display.variables.render_variables_table(template)
  170. def check_output_directory(
  171. output_dir: Path,
  172. rendered_files: dict[str, str],
  173. interactive: bool,
  174. display: DisplayManager,
  175. ) -> list[Path] | None:
  176. """Check output directory for conflicts and get user confirmation if needed."""
  177. dir_exists = output_dir.exists()
  178. dir_not_empty = dir_exists and any(output_dir.iterdir())
  179. # Check which files already exist
  180. existing_files = []
  181. if dir_exists:
  182. for file_path in rendered_files:
  183. full_path = output_dir / file_path
  184. if full_path.exists():
  185. existing_files.append(full_path)
  186. # Warn if directory is not empty
  187. if dir_not_empty:
  188. if interactive:
  189. display.text("") # Add newline before warning
  190. # Combine directory warning and file count on same line
  191. warning_msg = f"Directory '{output_dir}' is not empty."
  192. if existing_files:
  193. warning_msg += f" {len(existing_files)} file(s) will be overwritten."
  194. display.warning(warning_msg)
  195. display.text("") # Add newline after warning
  196. input_mgr = InputManager()
  197. if not input_mgr.confirm("Continue?", default=False):
  198. display.info("Generation cancelled")
  199. return None
  200. else:
  201. # Non-interactive mode: show warning but continue
  202. logger.warning(f"Directory '{output_dir}' is not empty")
  203. if existing_files:
  204. logger.warning(f"{len(existing_files)} file(s) will be overwritten")
  205. return existing_files
  206. def _analyze_file_operations(
  207. output_dir: Path, rendered_files: dict[str, str]
  208. ) -> tuple[list[tuple[str, int, str]], int, int, int]:
  209. """Analyze file operations and return statistics."""
  210. total_size = 0
  211. new_files = 0
  212. overwrite_files = 0
  213. file_operations = []
  214. for file_path, content in sorted(rendered_files.items()):
  215. full_path = output_dir / file_path
  216. file_size = len(content.encode("utf-8"))
  217. total_size += file_size
  218. if full_path.exists():
  219. status = "Overwrite"
  220. overwrite_files += 1
  221. else:
  222. status = "Create"
  223. new_files += 1
  224. file_operations.append((file_path, file_size, status))
  225. return file_operations, total_size, new_files, overwrite_files
  226. def _format_size(total_size: int) -> str:
  227. """Format byte size into human-readable string."""
  228. if total_size < BYTES_PER_KB:
  229. return f"{total_size}B"
  230. if total_size < BYTES_PER_MB:
  231. return f"{total_size / BYTES_PER_KB:.1f}KB"
  232. return f"{total_size / BYTES_PER_MB:.1f}MB"
  233. def _get_rendered_file_stats(rendered_files: dict[str, str]) -> tuple[int, int, str]:
  234. """Return file count, total size, and formatted size for rendered output."""
  235. total_size = sum(len(content.encode("utf-8")) for content in rendered_files.values())
  236. return len(rendered_files), total_size, _format_size(total_size)
  237. def _display_rendered_file_contents(rendered_files: dict[str, str], display: DisplayManager) -> None:
  238. """Display rendered file contents for dry-run mode."""
  239. display.text("")
  240. display.heading("File Contents")
  241. for file_path, content in sorted(rendered_files.items()):
  242. display.text(f"\n[cyan]{file_path}[/cyan]")
  243. display.text(f"{'─' * 80}")
  244. display.text(content)
  245. display.text("")
  246. def execute_dry_run(
  247. id: str,
  248. output_dir: Path,
  249. rendered_files: dict[str, str],
  250. show_files: bool,
  251. display: DisplayManager,
  252. ) -> tuple[int, int, str]:
  253. """Execute dry run mode - preview files without writing.
  254. Returns:
  255. Tuple of (total_files, overwrite_files, size_str) for summary display
  256. """
  257. _file_operations, total_size, _new_files, overwrite_files = _analyze_file_operations(output_dir, rendered_files)
  258. size_str = _format_size(total_size)
  259. if show_files:
  260. _display_rendered_file_contents(rendered_files, display)
  261. logger.info(f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes")
  262. return len(rendered_files), overwrite_files, size_str
  263. def execute_remote_dry_run(
  264. remote_host: str,
  265. remote_path: str,
  266. rendered_files: dict[str, str],
  267. show_files: bool,
  268. display: DisplayManager,
  269. ) -> tuple[int, str]:
  270. """Preview a remote upload without writing files."""
  271. total_files, _total_size, size_str = _get_rendered_file_stats(rendered_files)
  272. if show_files:
  273. _display_rendered_file_contents(rendered_files, display)
  274. logger.info(
  275. "Dry run completed for remote destination '%s' - %s files",
  276. format_remote_destination(remote_host, remote_path),
  277. total_files,
  278. )
  279. return total_files, size_str
  280. def _validate_output_name(name: str) -> str:
  281. """Validate and normalize a generated output name."""
  282. normalized_name = name.strip()
  283. if not normalized_name:
  284. raise ValueError("--name cannot be empty")
  285. if "/" in normalized_name or "\\" in normalized_name:
  286. raise ValueError("--name must be a file name, not a path")
  287. if normalized_name in {".", ".."}:
  288. raise ValueError("--name cannot be '.' or '..'")
  289. return normalized_name
  290. def _prefix_path_part(name: str, part: str) -> str:
  291. """Prefix one top-level path part with the generated output name."""
  292. path = Path(part)
  293. if path.stem == "main" and path.suffix:
  294. return f"{name}{path.suffix}"
  295. return f"{name}_{part}"
  296. def apply_output_name(rendered_files: dict[str, str], name: str | None) -> dict[str, str]:
  297. """Rename top-level generated paths with a user-provided output name.
  298. The top-level entrypoint file named ``main.<ext>`` becomes ``<name>.<ext>``.
  299. All other top-level files or directories receive ``<name>_`` as a prefix.
  300. Nested path segments are preserved unchanged.
  301. """
  302. if name is None:
  303. return rendered_files
  304. normalized_name = _validate_output_name(name)
  305. renamed_files: dict[str, str] = {}
  306. for file_path, content in rendered_files.items():
  307. path = Path(file_path)
  308. parts = path.parts
  309. if not parts:
  310. continue
  311. renamed_top_level = _prefix_path_part(normalized_name, parts[0])
  312. renamed_path = Path(renamed_top_level, *parts[1:]).as_posix()
  313. if renamed_path in renamed_files:
  314. raise ValueError(f"--name creates duplicate generated path: {renamed_path}")
  315. renamed_files[renamed_path] = content
  316. return renamed_files
  317. def write_rendered_files(output_dir: Path, rendered_files: dict[str, str]) -> None:
  318. """Write rendered files to the output directory."""
  319. output_dir.mkdir(parents=True, exist_ok=True)
  320. for file_path, content in rendered_files.items():
  321. full_path = output_dir / file_path
  322. full_path.parent.mkdir(parents=True, exist_ok=True)
  323. with full_path.open("w", encoding="utf-8") as f:
  324. f.write(content)
  325. logger.info(f"Template written to directory: {output_dir}")
  326. def _prepare_template(
  327. module_instance,
  328. id: str,
  329. var_file: str | None,
  330. var: list[str] | None,
  331. display: DisplayManager,
  332. ):
  333. """Load template and apply all defaults/overrides."""
  334. template = module_instance._load_template_by_id(id)
  335. config = ConfigManager()
  336. apply_variable_defaults(template, config, module_instance.name)
  337. apply_var_file(template, var_file, display)
  338. apply_cli_overrides(template, var)
  339. if template.variables:
  340. template.variables.sort_sections()
  341. reset_vars = template.variables.reset_disabled_bool_variables()
  342. if reset_vars:
  343. logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
  344. return template
  345. def _render_template(template, id: str, display: DisplayManager, interactive: bool):
  346. """Validate, render template and collect variable values."""
  347. variable_values = collect_variable_values(template, interactive)
  348. if template.variables:
  349. template.variables.validate_all()
  350. debug_mode = logger.isEnabledFor(logging.DEBUG)
  351. rendered_files, variable_values = template.render(template.variables, debug=debug_mode)
  352. if not rendered_files:
  353. display.error(
  354. "Template rendering returned no files",
  355. context="template generation",
  356. )
  357. raise Exit(code=1)
  358. logger.info(f"Successfully rendered template '{id}'")
  359. return rendered_files, variable_values
  360. def _display_template_error(display: DisplayManager, template_id: str, error: TemplateRenderError) -> None:
  361. """Display template rendering error with clean formatting."""
  362. display.text("")
  363. display.text("─" * 80, style="dim")
  364. display.text("")
  365. # Build details if available
  366. details = None
  367. if error.file_path:
  368. details = error.file_path
  369. if error.line_number:
  370. details += f":line {error.line_number}"
  371. # Display error with details
  372. display.error(f"Failed to generate boilerplate from template '{template_id}'", details=details)
  373. def _display_generic_error(display: DisplayManager, template_id: str, error: Exception) -> None:
  374. """Display generic error with clean formatting."""
  375. display.text("")
  376. display.text("─" * 80, style="dim")
  377. display.text("")
  378. # Truncate long error messages
  379. max_error_length = 100
  380. error_msg = str(error)
  381. if len(error_msg) > max_error_length:
  382. error_msg = f"{error_msg[:max_error_length]}..."
  383. # Display error with details
  384. display.error(f"Failed to generate boilerplate from template '{template_id}'", details=error_msg)
  385. def generate_template(module_instance, config: GenerationConfig) -> None: # noqa: PLR0912, PLR0915
  386. """Generate from template."""
  387. logger.info(f"Starting generation for template '{config.id}' from module '{module_instance.name}'")
  388. display = DisplayManager(quiet=config.quiet) if config.quiet else module_instance.display
  389. template = _prepare_template(module_instance, config.id, config.var_file, config.var, display)
  390. slug = getattr(template, "slug", template.id)
  391. used_implicit_dry_run_destination = False
  392. try:
  393. destination = resolve_cli_destination(config.output, config.remote, config.remote_path, slug)
  394. except ValueError as e:
  395. display.error(str(e), context="template generation")
  396. raise Exit(code=1) from None
  397. if not config.quiet:
  398. # Display template header
  399. module_instance.display.templates.render_template_header(template, config.id)
  400. # Display file tree
  401. module_instance.display.templates.render_file_tree(template)
  402. # Display variables table
  403. module_instance.display.variables.render_variables_table(template)
  404. module_instance.display.text("")
  405. try:
  406. rendered_files, _variable_values = _render_template(template, config.id, display, config.interactive)
  407. rendered_files = apply_output_name(rendered_files, config.name)
  408. if destination is None:
  409. if config.dry_run:
  410. destination = GenerationDestination(mode="local", local_output_dir=Path.cwd() / slug)
  411. used_implicit_dry_run_destination = True
  412. elif config.interactive:
  413. destination = prompt_generation_destination(slug)
  414. else:
  415. destination = GenerationDestination(mode="local", local_output_dir=Path.cwd() / slug)
  416. if not destination.is_remote:
  417. output_dir = destination.local_output_dir or (Path.cwd() / slug)
  418. if (
  419. not config.dry_run
  420. and not config.quiet
  421. and check_output_directory(output_dir, rendered_files, config.interactive, display) is None
  422. ):
  423. return
  424. # Execute generation (dry run or actual)
  425. dry_run_stats = None
  426. if destination.is_remote:
  427. remote_host = destination.remote_host or ""
  428. remote_path = destination.remote_path or f"~/{slug}"
  429. if config.dry_run:
  430. if not config.quiet:
  431. dry_run_stats = execute_remote_dry_run(
  432. remote_host,
  433. remote_path,
  434. rendered_files,
  435. config.show_files,
  436. display,
  437. )
  438. else:
  439. write_rendered_files_remote(remote_host, remote_path, rendered_files)
  440. else:
  441. output_dir = destination.local_output_dir or (Path.cwd() / slug)
  442. if config.dry_run:
  443. if not config.quiet:
  444. dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
  445. else:
  446. write_rendered_files(output_dir, rendered_files)
  447. # Display final status message at the end
  448. if not config.quiet:
  449. display.text("")
  450. display.text("─" * 80, style="dim")
  451. if destination.is_remote:
  452. remote_host = destination.remote_host or ""
  453. remote_path = destination.remote_path or f"~/{slug}"
  454. remote_target = format_remote_destination(remote_host, remote_path)
  455. if config.dry_run and dry_run_stats:
  456. total_files, size_str = dry_run_stats
  457. display.success(
  458. f"Dry run complete: {total_files} files ({size_str}) would be uploaded to '{remote_target}'"
  459. )
  460. else:
  461. display.success(f"Boilerplate uploaded successfully to '{remote_target}'")
  462. elif config.dry_run and dry_run_stats:
  463. total_files, overwrite_files, size_str = dry_run_stats
  464. if used_implicit_dry_run_destination:
  465. display.success(
  466. "Dry run complete: boilerplate rendered successfully "
  467. f"({total_files} files, {size_str}, preview only)"
  468. )
  469. elif overwrite_files > 0:
  470. display.warning(
  471. f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}' "
  472. f"({overwrite_files} would be overwritten)"
  473. )
  474. else:
  475. display.success(
  476. f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}'"
  477. )
  478. else:
  479. display.success(f"Boilerplate generated successfully in '{output_dir}'")
  480. except TemplateRenderError as e:
  481. _display_template_error(display, config.id, e)
  482. raise Exit(code=1) from None
  483. except Exception as e:
  484. _display_generic_error(display, config.id, e)
  485. raise Exit(code=1) from None
  486. def validate_templates(
  487. module_instance,
  488. template_id: str,
  489. path: str | None,
  490. config: ValidationConfig,
  491. ) -> None:
  492. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
  493. # Load template based on input
  494. if config.all_templates and (template_id or path):
  495. module_instance.display.error("--all cannot be combined with a template ID or --path")
  496. raise Exit(code=1) from None
  497. template = _load_template_for_validation(module_instance, template_id, path)
  498. if template:
  499. _validate_single_template(module_instance, template, template_id or template.id, config)
  500. else:
  501. _validate_all_templates(module_instance, config)
  502. def _load_template_for_validation(module_instance, template_id: str, path: str | None):
  503. """Load a template from path or ID for validation."""
  504. if path:
  505. template_path = Path(path).resolve()
  506. if not template_path.exists():
  507. module_instance.display.error(f"Path does not exist: {path}")
  508. raise Exit(code=1) from None
  509. if not template_path.is_dir():
  510. module_instance.display.error(f"Path is not a directory: {path}")
  511. raise Exit(code=1) from None
  512. module_instance.display.info(f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]")
  513. try:
  514. return Template(template_path, library_name="local")
  515. except Exception as e:
  516. module_instance.display.error(f"Failed to load template from path '{path}': {e}")
  517. raise Exit(code=1) from None
  518. if template_id:
  519. try:
  520. template = module_instance._load_template_by_id(template_id)
  521. module_instance.display.info(f"Validating template: {template_id}")
  522. return template
  523. except Exception as e:
  524. module_instance.display.error(f"Failed to load template '{template_id}': {e}")
  525. raise Exit(code=1) from None
  526. return None
  527. def _validate_single_template(
  528. module_instance,
  529. template,
  530. template_id: str,
  531. config: ValidationConfig,
  532. ) -> None:
  533. """Validate a single template."""
  534. try:
  535. # Jinja2 validation
  536. _ = template.used_variables
  537. _ = template.variables
  538. if not config.quiet_success:
  539. module_instance.display.success("Jinja2 validation passed")
  540. if config.matrix or config.kind:
  541. _run_matrix_validation(module_instance, template, config)
  542. return
  543. # Semantic validation for the default rendered output.
  544. if config.semantic:
  545. _run_semantic_validation(module_instance, template, config.verbose)
  546. # Verbose output
  547. if config.verbose:
  548. _display_validation_details(module_instance, template, config.semantic)
  549. except TemplateRenderError as e:
  550. module_instance.display.error(str(e), context=f"template '{template_id}'")
  551. raise Exit(code=1) from None
  552. except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
  553. module_instance.display.error(f"Validation failed for '{template_id}':")
  554. module_instance.display.info(f"\n{e}")
  555. raise Exit(code=1) from None
  556. except Exit:
  557. raise
  558. except Exception as e:
  559. module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
  560. raise Exit(code=1) from None
  561. def _run_matrix_validation(
  562. module_instance,
  563. template,
  564. config: ValidationConfig,
  565. ) -> None:
  566. """Run dependency matrix validation for one template."""
  567. module_instance.display.info("")
  568. module_instance.display.info("Running dependency matrix validation...")
  569. options = MatrixOptions(max_combinations=config.matrix_max_combinations)
  570. cases = DependencyMatrixBuilder(template, options).build()
  571. runner = ValidationRunner(
  572. template,
  573. cases,
  574. semantic=config.semantic,
  575. kind_validator=config.kind_validator,
  576. )
  577. summary = runner.run()
  578. module_instance.display.data_table(
  579. columns=[
  580. {"name": "Case", "style": "cyan", "no_wrap": False},
  581. {"name": "Tpl", "justify": "center"},
  582. {"name": "Sem", "justify": "center"},
  583. {"name": "Kind", "justify": "center"},
  584. ],
  585. rows=_build_matrix_result_rows(
  586. cases,
  587. summary.failures,
  588. kind_requested=config.kind,
  589. kind_available=config.kind_validator is not None,
  590. kind_skipped_cases=summary.kind_skipped_cases,
  591. ),
  592. title=f"Dependency Matrix ({len(cases)} cases)",
  593. )
  594. if config.kind and config.kind_validator is None:
  595. module_instance.display.warning(f"No kind-specific validator available for '{module_instance.name}'")
  596. elif summary.kind_skipped_cases:
  597. module_instance.display.warning("Kind-specific validation skipped for one or more cases")
  598. if summary.failures:
  599. module_instance.display.info("")
  600. for failure in summary.failures:
  601. location = f" [{failure.file_path}]" if failure.file_path else ""
  602. validator = f" ({failure.validator})" if failure.validator else ""
  603. module_instance.display.error(
  604. f"{failure.case_name}: {failure.stage}{location}{validator}: {failure.message}"
  605. )
  606. raise Exit(code=1) from None
  607. module_instance.display.success(f"Dependency matrix validation passed ({summary.total_cases} case(s))")
  608. def _build_matrix_result_rows(
  609. cases,
  610. failures,
  611. kind_requested: bool,
  612. kind_available: bool,
  613. kind_skipped_cases: set[str],
  614. ) -> list[tuple[str, str, str, str]]:
  615. """Build display rows for matrix validation results."""
  616. failures_by_case: dict[str, dict[str, set[str]]] = {}
  617. for failure in failures:
  618. case_failures = failures_by_case.setdefault(failure.case_name, {})
  619. case_failures.setdefault(failure.stage, set()).add(failure.message)
  620. rows = []
  621. for case in cases:
  622. failed_stages = failures_by_case.get(case.name, {})
  623. rows.append(
  624. (
  625. case.name,
  626. "fail" if "tpl" in failed_stages else "pass",
  627. "fail" if "sem" in failed_stages else "pass",
  628. _matrix_stage_status(
  629. failed_stages,
  630. "kind",
  631. requested=kind_requested,
  632. available=kind_available,
  633. skipped=case.name in kind_skipped_cases,
  634. ),
  635. )
  636. )
  637. return rows
  638. def _matrix_stage_status(
  639. failed_stages: dict[str, set[str]],
  640. stage: str,
  641. *,
  642. requested: bool = True,
  643. available: bool = True,
  644. skipped: bool = False,
  645. ) -> str:
  646. if not requested:
  647. return "skip"
  648. if not available:
  649. return "missing"
  650. if any("not available" in message or "unavailable" in message for message in failed_stages.get(stage, set())):
  651. return "missing"
  652. if stage in failed_stages:
  653. return "fail"
  654. if skipped:
  655. return "skip"
  656. return "pass"
  657. def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
  658. """Run semantic validation on the default rendered template files."""
  659. module_instance.display.info("")
  660. module_instance.display.info("Running semantic validation...")
  661. registry = get_validator_registry()
  662. debug_mode = logger.isEnabledFor(logging.DEBUG)
  663. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  664. has_semantic_errors = False
  665. for file_path, content in rendered_files.items():
  666. result = registry.validate_file(content, file_path)
  667. if result.errors or result.warnings or (verbose and result.info):
  668. module_instance.display.info(f"\nFile: {file_path}")
  669. result.display(f"{file_path}")
  670. if result.errors:
  671. has_semantic_errors = True
  672. if has_semantic_errors:
  673. module_instance.display.error("Semantic validation found errors")
  674. raise Exit(code=1) from None
  675. module_instance.display.success("Semantic validation passed")
  676. def _display_validation_details(module_instance, template, semantic: bool) -> None:
  677. """Display verbose validation details."""
  678. module_instance.display.info(f"\nTemplate path: {template.template_dir}")
  679. module_instance.display.info(f"Found {len(template.used_variables)} variables")
  680. if semantic:
  681. debug_mode = logger.isEnabledFor(logging.DEBUG)
  682. rendered_files, _ = template.render(template.variables, debug=debug_mode)
  683. module_instance.display.info(f"Generated {len(rendered_files)} files")
  684. def _validate_all_templates(module_instance, config: ValidationConfig) -> None:
  685. """Validate all templates in the module."""
  686. module_instance.display.info(f"Validating all {module_instance.name} templates...")
  687. valid_count = 0
  688. invalid_count = 0
  689. errors = []
  690. all_templates = module_instance._load_all_templates()
  691. total = len(all_templates)
  692. # Preserve historical all-template validation behavior: by default this
  693. # checks template syntax/variables only. Matrix or kind validation can be
  694. # explicitly enabled for all templates with --matrix/--kind.
  695. child_config = replace(
  696. config,
  697. semantic=config.semantic if config.matrix or config.kind else False,
  698. quiet_success=not config.verbose,
  699. )
  700. for template in all_templates:
  701. try:
  702. _validate_single_template(module_instance, template, template.id, child_config)
  703. valid_count += 1
  704. if config.verbose:
  705. module_instance.display.success(template.id)
  706. except Exit:
  707. invalid_count += 1
  708. errors.append((template.id, "Validation failed"))
  709. if config.verbose:
  710. module_instance.display.error(template.id)
  711. except ValueError as e:
  712. invalid_count += 1
  713. errors.append((template.id, str(e)))
  714. if config.verbose:
  715. module_instance.display.error(template.id)
  716. except Exception as e:
  717. invalid_count += 1
  718. errors.append((template.id, f"Load error: {e}"))
  719. if config.verbose:
  720. module_instance.display.warning(template.id)
  721. # Display summary
  722. module_instance.display.info("")
  723. module_instance.display.info(f"Total templates: {total}")
  724. module_instance.display.info(f"Valid: {valid_count}")
  725. module_instance.display.info(f"Invalid: {invalid_count}")
  726. if errors:
  727. module_instance.display.info("")
  728. for template_id, error_msg in errors:
  729. module_instance.display.error(f"{template_id}: {error_msg}")
  730. raise Exit(code=1)
  731. if total > 0:
  732. module_instance.display.info("")
  733. module_instance.display.success("All templates are valid")