| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881 |
- """Base commands for module: list, search, show, validate, generate."""
- from __future__ import annotations
- import logging
- from dataclasses import dataclass, replace
- from pathlib import Path
- from typer import Exit
- from ..config import ConfigManager
- from ..display import DisplayManager, IconManager
- from ..exceptions import (
- TemplateRenderError,
- TemplateSyntaxError,
- TemplateValidationError,
- )
- from ..input import InputManager
- from ..template import Template
- from ..validation import DependencyMatrixBuilder, MatrixOptions, ValidationRunner
- from ..validators import get_validator_registry
- from .generation_destination import (
- GenerationDestination,
- format_remote_destination,
- prompt_generation_destination,
- resolve_cli_destination,
- write_rendered_files_remote,
- )
- from .helpers import (
- apply_cli_overrides,
- apply_var_file,
- apply_variable_defaults,
- collect_variable_values,
- )
- logger = logging.getLogger(__name__)
- # File size thresholds for display formatting
- BYTES_PER_KB = 1024
- BYTES_PER_MB = 1024 * 1024
- @dataclass
- class GenerationConfig:
- """Configuration for template generation."""
- id: str
- output: str | None = None
- remote: str | None = None
- remote_path: str | None = None
- interactive: bool = True
- var: list[str] | None = None
- var_file: str | None = None
- dry_run: bool = False
- show_files: bool = False
- quiet: bool = False
- name: str | None = None
- @dataclass
- class ValidationConfig:
- """Configuration for template validation."""
- verbose: bool
- semantic: bool = True
- matrix: bool = False
- kind: bool = False
- all_templates: bool = False
- matrix_max_combinations: int = 100
- kind_validator: object | None = None
- quiet_success: bool = False
- def list_templates(module_instance, raw: bool = False) -> list:
- """List all templates."""
- logger.debug(f"Listing templates for module '{module_instance.name}'")
- # Load all templates using centralized helper
- filtered_templates = module_instance._load_all_templates()
- if filtered_templates:
- if raw:
- # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
- # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
- for template in filtered_templates:
- name = template.metadata.name or "Unnamed Template"
- tags_list = template.metadata.tags or []
- tags = ",".join(tags_list) if tags_list else "-"
- version = template.metadata.version.name if template.metadata.version else "-"
- library = template.metadata.library or "-"
- module_instance.display.text("\t".join([template.id, name, tags, version, library]))
- else:
- # Output rich table format
- def format_template_row(template):
- name = template.metadata.name or "Unnamed Template"
- tags_list = template.metadata.tags or []
- tags = ", ".join(tags_list) if tags_list else "-"
- version = template.metadata.version.name if template.metadata.version else ""
- library_name = template.metadata.library or ""
- library_type = template.metadata.library_type or "git"
- # Format library with icon and color
- icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
- color = "yellow" if library_type == "static" else "blue"
- library_display = f"[{color}]{icon} {library_name}[/{color}]"
- return (template.id, name, tags, version, library_display)
- module_instance.display.data_table(
- columns=[
- {"name": "ID", "style": "bold", "no_wrap": True},
- {"name": "Name"},
- {"name": "Tags"},
- {"name": "Version", "no_wrap": True},
- {"name": "Library", "no_wrap": True},
- ],
- rows=filtered_templates,
- row_formatter=format_template_row,
- )
- else:
- logger.info(f"No templates found for module '{module_instance.name}'")
- module_instance.display.info(
- f"No templates found for module '{module_instance.name}'",
- context="Use 'bp repo update' to update libraries or check library configuration",
- )
- return filtered_templates
- def search_templates(module_instance, query: str) -> list:
- """Search for templates by ID containing the search string."""
- logger.debug(f"Searching templates for module '{module_instance.name}' with query='{query}'")
- # Load templates with search filter using centralized helper
- filtered_templates = module_instance._load_all_templates(lambda t: query.lower() in t.id.lower())
- if filtered_templates:
- logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{module_instance.name}'")
- def format_template_row(template):
- name = template.metadata.name or "Unnamed Template"
- tags_list = template.metadata.tags or []
- tags = ", ".join(tags_list) if tags_list else "-"
- version = template.metadata.version.name if template.metadata.version else ""
- library_name = template.metadata.library or ""
- library_type = template.metadata.library_type or "git"
- # Format library with icon and color
- icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
- color = "yellow" if library_type == "static" else "blue"
- library_display = f"[{color}]{icon} {library_name}[/{color}]"
- return (template.id, name, tags, version, library_display)
- module_instance.display.data_table(
- columns=[
- {"name": "ID", "style": "bold", "no_wrap": True},
- {"name": "Name"},
- {"name": "Tags"},
- {"name": "Version", "no_wrap": True},
- {"name": "Library", "no_wrap": True},
- ],
- rows=filtered_templates,
- row_formatter=format_template_row,
- )
- else:
- logger.info(f"No templates found matching '{query}' for module '{module_instance.name}'")
- module_instance.display.warning(
- f"No templates found matching '{query}'",
- context=f"module '{module_instance.name}'",
- )
- return filtered_templates
- def show_template(module_instance, id: str, var: list[str] | None = None, var_file: str | None = None) -> None:
- """Show template details with optional variable overrides."""
- logger.debug(f"Showing template '{id}' from module '{module_instance.name}'")
- template = module_instance._load_template_by_id(id)
- if not template:
- module_instance.display.error(f"Template '{id}' not found", context=f"module '{module_instance.name}'")
- return
- # Apply defaults and overrides (same precedence as generate command)
- if template.variables:
- config = ConfigManager()
- apply_variable_defaults(template, config, module_instance.name)
- apply_var_file(template, var_file, module_instance.display)
- apply_cli_overrides(template, var)
- # Re-sort sections after applying overrides (toggle values may have changed)
- template.variables.sort_sections()
- # Reset disabled bool variables to False to prevent confusion
- reset_vars = template.variables.reset_disabled_bool_variables()
- if reset_vars:
- logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
- # Display template header
- module_instance.display.templates.render_template_header(template, id)
- # Display file tree
- module_instance.display.templates.render_file_tree(template)
- # Display variables table
- module_instance.display.variables.render_variables_table(template)
- def check_output_directory(
- output_dir: Path,
- rendered_files: dict[str, str],
- interactive: bool,
- display: DisplayManager,
- ) -> list[Path] | None:
- """Check output directory for conflicts and get user confirmation if needed."""
- dir_exists = output_dir.exists()
- dir_not_empty = dir_exists and any(output_dir.iterdir())
- # Check which files already exist
- existing_files = []
- if dir_exists:
- for file_path in rendered_files:
- full_path = output_dir / file_path
- if full_path.exists():
- existing_files.append(full_path)
- # Warn if directory is not empty
- if dir_not_empty:
- if interactive:
- display.text("") # Add newline before warning
- # Combine directory warning and file count on same line
- warning_msg = f"Directory '{output_dir}' is not empty."
- if existing_files:
- warning_msg += f" {len(existing_files)} file(s) will be overwritten."
- display.warning(warning_msg)
- display.text("") # Add newline after warning
- input_mgr = InputManager()
- if not input_mgr.confirm("Continue?", default=False):
- display.info("Generation cancelled")
- return None
- else:
- # Non-interactive mode: show warning but continue
- logger.warning(f"Directory '{output_dir}' is not empty")
- if existing_files:
- logger.warning(f"{len(existing_files)} file(s) will be overwritten")
- return existing_files
- def _analyze_file_operations(
- output_dir: Path, rendered_files: dict[str, str]
- ) -> tuple[list[tuple[str, int, str]], int, int, int]:
- """Analyze file operations and return statistics."""
- total_size = 0
- new_files = 0
- overwrite_files = 0
- file_operations = []
- for file_path, content in sorted(rendered_files.items()):
- full_path = output_dir / file_path
- file_size = len(content.encode("utf-8"))
- total_size += file_size
- if full_path.exists():
- status = "Overwrite"
- overwrite_files += 1
- else:
- status = "Create"
- new_files += 1
- file_operations.append((file_path, file_size, status))
- return file_operations, total_size, new_files, overwrite_files
- def _format_size(total_size: int) -> str:
- """Format byte size into human-readable string."""
- if total_size < BYTES_PER_KB:
- return f"{total_size}B"
- if total_size < BYTES_PER_MB:
- return f"{total_size / BYTES_PER_KB:.1f}KB"
- return f"{total_size / BYTES_PER_MB:.1f}MB"
- def _get_rendered_file_stats(rendered_files: dict[str, str]) -> tuple[int, int, str]:
- """Return file count, total size, and formatted size for rendered output."""
- total_size = sum(len(content.encode("utf-8")) for content in rendered_files.values())
- return len(rendered_files), total_size, _format_size(total_size)
- def _display_rendered_file_contents(rendered_files: dict[str, str], display: DisplayManager) -> None:
- """Display rendered file contents for dry-run mode."""
- display.text("")
- display.heading("File Contents")
- for file_path, content in sorted(rendered_files.items()):
- display.text(f"\n[cyan]{file_path}[/cyan]")
- display.text(f"{'─' * 80}")
- display.text(content)
- display.text("")
- def execute_dry_run(
- id: str,
- output_dir: Path,
- rendered_files: dict[str, str],
- show_files: bool,
- display: DisplayManager,
- ) -> tuple[int, int, str]:
- """Execute dry run mode - preview files without writing.
- Returns:
- Tuple of (total_files, overwrite_files, size_str) for summary display
- """
- _file_operations, total_size, _new_files, overwrite_files = _analyze_file_operations(output_dir, rendered_files)
- size_str = _format_size(total_size)
- if show_files:
- _display_rendered_file_contents(rendered_files, display)
- logger.info(f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes")
- return len(rendered_files), overwrite_files, size_str
- def execute_remote_dry_run(
- remote_host: str,
- remote_path: str,
- rendered_files: dict[str, str],
- show_files: bool,
- display: DisplayManager,
- ) -> tuple[int, str]:
- """Preview a remote upload without writing files."""
- total_files, _total_size, size_str = _get_rendered_file_stats(rendered_files)
- if show_files:
- _display_rendered_file_contents(rendered_files, display)
- logger.info(
- "Dry run completed for remote destination '%s' - %s files",
- format_remote_destination(remote_host, remote_path),
- total_files,
- )
- return total_files, size_str
- def _validate_output_name(name: str) -> str:
- """Validate and normalize a generated output name."""
- normalized_name = name.strip()
- if not normalized_name:
- raise ValueError("--name cannot be empty")
- if "/" in normalized_name or "\\" in normalized_name:
- raise ValueError("--name must be a file name, not a path")
- if normalized_name in {".", ".."}:
- raise ValueError("--name cannot be '.' or '..'")
- return normalized_name
- def _prefix_path_part(name: str, part: str) -> str:
- """Prefix one top-level path part with the generated output name."""
- path = Path(part)
- if path.stem == "main" and path.suffix:
- return f"{name}{path.suffix}"
- return f"{name}_{part}"
- def apply_output_name(rendered_files: dict[str, str], name: str | None) -> dict[str, str]:
- """Rename top-level generated paths with a user-provided output name.
- The top-level entrypoint file named ``main.<ext>`` becomes ``<name>.<ext>``.
- All other top-level files or directories receive ``<name>_`` as a prefix.
- Nested path segments are preserved unchanged.
- """
- if name is None:
- return rendered_files
- normalized_name = _validate_output_name(name)
- renamed_files: dict[str, str] = {}
- for file_path, content in rendered_files.items():
- path = Path(file_path)
- parts = path.parts
- if not parts:
- continue
- renamed_top_level = _prefix_path_part(normalized_name, parts[0])
- renamed_path = Path(renamed_top_level, *parts[1:]).as_posix()
- if renamed_path in renamed_files:
- raise ValueError(f"--name creates duplicate generated path: {renamed_path}")
- renamed_files[renamed_path] = content
- return renamed_files
- def write_rendered_files(output_dir: Path, rendered_files: dict[str, str]) -> None:
- """Write rendered files to the output directory."""
- output_dir.mkdir(parents=True, exist_ok=True)
- for file_path, content in rendered_files.items():
- full_path = output_dir / file_path
- full_path.parent.mkdir(parents=True, exist_ok=True)
- with full_path.open("w", encoding="utf-8") as f:
- f.write(content)
- logger.info(f"Template written to directory: {output_dir}")
- def _prepare_template(
- module_instance,
- id: str,
- var_file: str | None,
- var: list[str] | None,
- display: DisplayManager,
- ):
- """Load template and apply all defaults/overrides."""
- template = module_instance._load_template_by_id(id)
- config = ConfigManager()
- apply_variable_defaults(template, config, module_instance.name)
- apply_var_file(template, var_file, display)
- apply_cli_overrides(template, var)
- if template.variables:
- template.variables.sort_sections()
- reset_vars = template.variables.reset_disabled_bool_variables()
- if reset_vars:
- logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
- return template
- def _render_template(template, id: str, display: DisplayManager, interactive: bool):
- """Validate, render template and collect variable values."""
- variable_values = collect_variable_values(template, interactive)
- if template.variables:
- template.variables.validate_all()
- debug_mode = logger.isEnabledFor(logging.DEBUG)
- rendered_files, variable_values = template.render(template.variables, debug=debug_mode)
- if not rendered_files:
- display.error(
- "Template rendering returned no files",
- context="template generation",
- )
- raise Exit(code=1)
- logger.info(f"Successfully rendered template '{id}'")
- return rendered_files, variable_values
- def _display_template_error(display: DisplayManager, template_id: str, error: TemplateRenderError) -> None:
- """Display template rendering error with clean formatting."""
- display.text("")
- display.text("─" * 80, style="dim")
- display.text("")
- # Build details if available
- details = None
- if error.file_path:
- details = error.file_path
- if error.line_number:
- details += f":line {error.line_number}"
- # Display error with details
- display.error(f"Failed to generate boilerplate from template '{template_id}'", details=details)
- def _display_generic_error(display: DisplayManager, template_id: str, error: Exception) -> None:
- """Display generic error with clean formatting."""
- display.text("")
- display.text("─" * 80, style="dim")
- display.text("")
- # Truncate long error messages
- max_error_length = 100
- error_msg = str(error)
- if len(error_msg) > max_error_length:
- error_msg = f"{error_msg[:max_error_length]}..."
- # Display error with details
- display.error(f"Failed to generate boilerplate from template '{template_id}'", details=error_msg)
- def generate_template(module_instance, config: GenerationConfig) -> None: # noqa: PLR0912, PLR0915
- """Generate from template."""
- logger.info(f"Starting generation for template '{config.id}' from module '{module_instance.name}'")
- display = DisplayManager(quiet=config.quiet) if config.quiet else module_instance.display
- template = _prepare_template(module_instance, config.id, config.var_file, config.var, display)
- slug = getattr(template, "slug", template.id)
- used_implicit_dry_run_destination = False
- try:
- destination = resolve_cli_destination(config.output, config.remote, config.remote_path, slug)
- except ValueError as e:
- display.error(str(e), context="template generation")
- raise Exit(code=1) from None
- if not config.quiet:
- # Display template header
- module_instance.display.templates.render_template_header(template, config.id)
- # Display file tree
- module_instance.display.templates.render_file_tree(template)
- # Display variables table
- module_instance.display.variables.render_variables_table(template)
- module_instance.display.text("")
- try:
- rendered_files, _variable_values = _render_template(template, config.id, display, config.interactive)
- rendered_files = apply_output_name(rendered_files, config.name)
- if destination is None:
- if config.dry_run:
- destination = GenerationDestination(mode="local", local_output_dir=Path.cwd() / slug)
- used_implicit_dry_run_destination = True
- elif config.interactive:
- destination = prompt_generation_destination(slug)
- else:
- destination = GenerationDestination(mode="local", local_output_dir=Path.cwd() / slug)
- if not destination.is_remote:
- output_dir = destination.local_output_dir or (Path.cwd() / slug)
- if (
- not config.dry_run
- and not config.quiet
- and check_output_directory(output_dir, rendered_files, config.interactive, display) is None
- ):
- return
- # Execute generation (dry run or actual)
- dry_run_stats = None
- if destination.is_remote:
- remote_host = destination.remote_host or ""
- remote_path = destination.remote_path or f"~/{slug}"
- if config.dry_run:
- if not config.quiet:
- dry_run_stats = execute_remote_dry_run(
- remote_host,
- remote_path,
- rendered_files,
- config.show_files,
- display,
- )
- else:
- write_rendered_files_remote(remote_host, remote_path, rendered_files)
- else:
- output_dir = destination.local_output_dir or (Path.cwd() / slug)
- if config.dry_run:
- if not config.quiet:
- dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
- else:
- write_rendered_files(output_dir, rendered_files)
- # Display final status message at the end
- if not config.quiet:
- display.text("")
- display.text("─" * 80, style="dim")
- if destination.is_remote:
- remote_host = destination.remote_host or ""
- remote_path = destination.remote_path or f"~/{slug}"
- remote_target = format_remote_destination(remote_host, remote_path)
- if config.dry_run and dry_run_stats:
- total_files, size_str = dry_run_stats
- display.success(
- f"Dry run complete: {total_files} files ({size_str}) would be uploaded to '{remote_target}'"
- )
- else:
- display.success(f"Boilerplate uploaded successfully to '{remote_target}'")
- elif config.dry_run and dry_run_stats:
- total_files, overwrite_files, size_str = dry_run_stats
- if used_implicit_dry_run_destination:
- display.success(
- "Dry run complete: boilerplate rendered successfully "
- f"({total_files} files, {size_str}, preview only)"
- )
- elif overwrite_files > 0:
- display.warning(
- f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}' "
- f"({overwrite_files} would be overwritten)"
- )
- else:
- display.success(
- f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}'"
- )
- else:
- display.success(f"Boilerplate generated successfully in '{output_dir}'")
- except TemplateRenderError as e:
- _display_template_error(display, config.id, e)
- raise Exit(code=1) from None
- except Exception as e:
- _display_generic_error(display, config.id, e)
- raise Exit(code=1) from None
- def validate_templates(
- module_instance,
- template_id: str,
- path: str | None,
- config: ValidationConfig,
- ) -> None:
- """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
- # Load template based on input
- if config.all_templates and (template_id or path):
- module_instance.display.error("--all cannot be combined with a template ID or --path")
- raise Exit(code=1) from None
- template = _load_template_for_validation(module_instance, template_id, path)
- if template:
- _validate_single_template(module_instance, template, template_id or template.id, config)
- else:
- _validate_all_templates(module_instance, config)
- def _load_template_for_validation(module_instance, template_id: str, path: str | None):
- """Load a template from path or ID for validation."""
- if path:
- template_path = Path(path).resolve()
- if not template_path.exists():
- module_instance.display.error(f"Path does not exist: {path}")
- raise Exit(code=1) from None
- if not template_path.is_dir():
- module_instance.display.error(f"Path is not a directory: {path}")
- raise Exit(code=1) from None
- module_instance.display.info(f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]")
- try:
- return Template(template_path, library_name="local")
- except Exception as e:
- module_instance.display.error(f"Failed to load template from path '{path}': {e}")
- raise Exit(code=1) from None
- if template_id:
- try:
- template = module_instance._load_template_by_id(template_id)
- module_instance.display.info(f"Validating template: {template_id}")
- return template
- except Exception as e:
- module_instance.display.error(f"Failed to load template '{template_id}': {e}")
- raise Exit(code=1) from None
- return None
- def _validate_single_template(
- module_instance,
- template,
- template_id: str,
- config: ValidationConfig,
- ) -> None:
- """Validate a single template."""
- try:
- # Jinja2 validation
- _ = template.used_variables
- _ = template.variables
- if not config.quiet_success:
- module_instance.display.success("Jinja2 validation passed")
- if config.matrix or config.kind:
- _run_matrix_validation(module_instance, template, config)
- return
- # Semantic validation for the default rendered output.
- if config.semantic:
- _run_semantic_validation(module_instance, template, config.verbose)
- # Verbose output
- if config.verbose:
- _display_validation_details(module_instance, template, config.semantic)
- except TemplateRenderError as e:
- module_instance.display.error(str(e), context=f"template '{template_id}'")
- raise Exit(code=1) from None
- except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
- module_instance.display.error(f"Validation failed for '{template_id}':")
- module_instance.display.info(f"\n{e}")
- raise Exit(code=1) from None
- except Exit:
- raise
- except Exception as e:
- module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
- raise Exit(code=1) from None
- def _run_matrix_validation(
- module_instance,
- template,
- config: ValidationConfig,
- ) -> None:
- """Run dependency matrix validation for one template."""
- module_instance.display.info("")
- module_instance.display.info("Running dependency matrix validation...")
- options = MatrixOptions(max_combinations=config.matrix_max_combinations)
- cases = DependencyMatrixBuilder(template, options).build()
- runner = ValidationRunner(
- template,
- cases,
- semantic=config.semantic,
- kind_validator=config.kind_validator,
- )
- summary = runner.run()
- module_instance.display.data_table(
- columns=[
- {"name": "Case", "style": "cyan", "no_wrap": False},
- {"name": "Tpl", "justify": "center"},
- {"name": "Sem", "justify": "center"},
- {"name": "Kind", "justify": "center"},
- ],
- rows=_build_matrix_result_rows(
- cases,
- summary.failures,
- kind_requested=config.kind,
- kind_available=config.kind_validator is not None,
- kind_skipped_cases=summary.kind_skipped_cases,
- ),
- title=f"Dependency Matrix ({len(cases)} cases)",
- )
- if config.kind and config.kind_validator is None:
- module_instance.display.warning(f"No kind-specific validator available for '{module_instance.name}'")
- elif summary.kind_skipped_cases:
- module_instance.display.warning("Kind-specific validation skipped for one or more cases")
- if summary.failures:
- module_instance.display.info("")
- for failure in summary.failures:
- location = f" [{failure.file_path}]" if failure.file_path else ""
- validator = f" ({failure.validator})" if failure.validator else ""
- module_instance.display.error(
- f"{failure.case_name}: {failure.stage}{location}{validator}: {failure.message}"
- )
- raise Exit(code=1) from None
- module_instance.display.success(f"Dependency matrix validation passed ({summary.total_cases} case(s))")
- def _build_matrix_result_rows(
- cases,
- failures,
- kind_requested: bool,
- kind_available: bool,
- kind_skipped_cases: set[str],
- ) -> list[tuple[str, str, str, str]]:
- """Build display rows for matrix validation results."""
- failures_by_case: dict[str, dict[str, set[str]]] = {}
- for failure in failures:
- case_failures = failures_by_case.setdefault(failure.case_name, {})
- case_failures.setdefault(failure.stage, set()).add(failure.message)
- rows = []
- for case in cases:
- failed_stages = failures_by_case.get(case.name, {})
- rows.append(
- (
- case.name,
- "fail" if "tpl" in failed_stages else "pass",
- "fail" if "sem" in failed_stages else "pass",
- _matrix_stage_status(
- failed_stages,
- "kind",
- requested=kind_requested,
- available=kind_available,
- skipped=case.name in kind_skipped_cases,
- ),
- )
- )
- return rows
- def _matrix_stage_status(
- failed_stages: dict[str, set[str]],
- stage: str,
- *,
- requested: bool = True,
- available: bool = True,
- skipped: bool = False,
- ) -> str:
- if not requested:
- return "skip"
- if not available:
- return "missing"
- if any("not available" in message or "unavailable" in message for message in failed_stages.get(stage, set())):
- return "missing"
- if stage in failed_stages:
- return "fail"
- if skipped:
- return "skip"
- return "pass"
- def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
- """Run semantic validation on the default rendered template files."""
- module_instance.display.info("")
- module_instance.display.info("Running semantic validation...")
- registry = get_validator_registry()
- debug_mode = logger.isEnabledFor(logging.DEBUG)
- rendered_files, _ = template.render(template.variables, debug=debug_mode)
- has_semantic_errors = False
- for file_path, content in rendered_files.items():
- result = registry.validate_file(content, file_path)
- if result.errors or result.warnings or (verbose and result.info):
- module_instance.display.info(f"\nFile: {file_path}")
- result.display(f"{file_path}")
- if result.errors:
- has_semantic_errors = True
- if has_semantic_errors:
- module_instance.display.error("Semantic validation found errors")
- raise Exit(code=1) from None
- module_instance.display.success("Semantic validation passed")
- def _display_validation_details(module_instance, template, semantic: bool) -> None:
- """Display verbose validation details."""
- module_instance.display.info(f"\nTemplate path: {template.template_dir}")
- module_instance.display.info(f"Found {len(template.used_variables)} variables")
- if semantic:
- debug_mode = logger.isEnabledFor(logging.DEBUG)
- rendered_files, _ = template.render(template.variables, debug=debug_mode)
- module_instance.display.info(f"Generated {len(rendered_files)} files")
- def _validate_all_templates(module_instance, config: ValidationConfig) -> None:
- """Validate all templates in the module."""
- module_instance.display.info(f"Validating all {module_instance.name} templates...")
- valid_count = 0
- invalid_count = 0
- errors = []
- all_templates = module_instance._load_all_templates()
- total = len(all_templates)
- # Preserve historical all-template validation behavior: by default this
- # checks template syntax/variables only. Matrix or kind validation can be
- # explicitly enabled for all templates with --matrix/--kind.
- child_config = replace(
- config,
- semantic=config.semantic if config.matrix or config.kind else False,
- quiet_success=not config.verbose,
- )
- for template in all_templates:
- try:
- _validate_single_template(module_instance, template, template.id, child_config)
- valid_count += 1
- if config.verbose:
- module_instance.display.success(template.id)
- except Exit:
- invalid_count += 1
- errors.append((template.id, "Validation failed"))
- if config.verbose:
- module_instance.display.error(template.id)
- except ValueError as e:
- invalid_count += 1
- errors.append((template.id, str(e)))
- if config.verbose:
- module_instance.display.error(template.id)
- except Exception as e:
- invalid_count += 1
- errors.append((template.id, f"Load error: {e}"))
- if config.verbose:
- module_instance.display.warning(template.id)
- # Display summary
- module_instance.display.info("")
- module_instance.display.info(f"Total templates: {total}")
- module_instance.display.info(f"Valid: {valid_count}")
- module_instance.display.info(f"Invalid: {invalid_count}")
- if errors:
- module_instance.display.info("")
- for template_id, error_msg in errors:
- module_instance.display.error(f"{template_id}: {error_msg}")
- raise Exit(code=1)
- if total > 0:
- module_instance.display.info("")
- module_instance.display.success("All templates are valid")
|