Files

391 lines
13 KiB
Python

"""Base module class for template management."""
from __future__ import annotations
import logging
from abc import ABC
from typing import Annotated
from typer import Argument, Option, Typer
from ..display import DisplayManager
from ..library import LibraryManager
from ..template import Template
from .base_commands import (
GenerationConfig,
ValidationConfig,
generate_template,
list_templates,
search_templates,
show_template,
validate_templates,
)
from .config_commands import (
config_clear,
config_get,
config_list,
config_remove,
config_set,
)
logger = logging.getLogger(__name__)
# Expected length of library entry tuple: (path, library_name, needs_qualification)
LIBRARY_ENTRY_MIN_LENGTH = 2
class Module(ABC):
"""Streamlined base module that auto-detects variables from templates.
Subclasses must define:
- name: str (class attribute)
- description: str (class attribute)
"""
# Class attributes that must be defined by subclasses
name: str
description: str
kind_validator_class = None
def __init__(self) -> None:
# Validate required class attributes
if not hasattr(self.__class__, "name") or not hasattr(self.__class__, "description"):
raise TypeError(f"Module {self.__class__.__name__} must define 'name' and 'description' class attributes")
logger.info(f"Initializing module '{self.name}'")
logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
self.libraries = LibraryManager()
self.display = DisplayManager()
def _load_all_templates(self, filter_fn=None) -> list:
"""Load all templates for this module with optional filtering."""
templates = []
entries = self.libraries.find(self.name, sort_results=True)
for entry in entries:
# Unpack entry - returns (path, library_name, needs_qualification)
template_dir = entry[0]
library_name = entry[1]
needs_qualification = entry[2] if len(entry) > LIBRARY_ENTRY_MIN_LENGTH else False
try:
# Get library object to determine type
library = next(
(lib for lib in self.libraries.libraries if lib.name == library_name),
None,
)
library_type = library.library_type if library else "git"
template = Template(template_dir, library_name=library_name, library_type=library_type)
# If template ID needs qualification, set qualified ID
if needs_qualification:
template.set_qualified_id()
# Apply filter if provided
if filter_fn is None or filter_fn(template):
templates.append(template)
except Exception as exc:
logger.error(f"Failed to load template from {template_dir}: {exc}")
continue
return templates
def _load_template_by_id(self, id: str):
"""Load a template by its ID, supporting qualified IDs."""
logger.debug(f"Loading template with ID '{id}' from module '{self.name}'")
# find_by_id now handles both simple and qualified IDs
result = self.libraries.find_by_id(self.name, id)
if not result:
raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
template_dir, library_name = result
# Get library type
library = next((lib for lib in self.libraries.libraries if lib.name == library_name), None)
library_type = library.library_type if library else "git"
try:
template = Template(template_dir, library_name=library_name, library_type=library_type)
# If the original ID was qualified, preserve it
if "." in id:
template.id = id
return template
except Exception as exc:
logger.error(f"Failed to load template '{id}': {exc}")
raise FileNotFoundError(f"Template '{id}' could not be loaded: {exc}") from exc
def list(
self,
raw: Annotated[bool, Option("--raw", help="Output raw list format instead of rich table")] = False,
) -> list:
"""List all templates."""
return list_templates(self, raw)
def search(
self,
query: Annotated[str, Argument(help="Search string to filter templates by ID")],
) -> list:
"""Search for templates by ID containing the search string."""
return search_templates(self, query)
def show(
self,
id: str,
var: Annotated[
list[str] | None,
Option(
"--var",
"-v",
help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
),
] = None,
var_file: Annotated[
str | None,
Option(
"--var-file",
"-f",
help="Load variables from YAML file (overrides config defaults)",
),
] = None,
) -> None:
"""Show template details with optional variable overrides."""
return show_template(self, id, var, var_file)
def generate(
self,
id: Annotated[str, Argument(help="Template ID")],
*,
output: Annotated[
str | None,
Option(
"--output",
"-o",
help="Local output directory",
),
] = None,
remote: Annotated[
str | None,
Option(
"--remote",
"-r",
help="Upload generated files to this SSH host instead of a local directory",
),
] = None,
remote_path: Annotated[
str | None,
Option(
"--remote-path",
help="Remote target directory (defaults to ~/<slug> when --remote is used)",
),
] = None,
interactive: Annotated[
bool,
Option(
"--interactive/--no-interactive",
help="Enable interactive prompting for variables",
),
] = True,
name: Annotated[
str | None,
Option(
"--name",
"-n",
help="Rename top-level generated files/directories with this name",
),
] = None,
var: Annotated[
list[str] | None,
Option(
"--var",
"-v",
help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
),
] = None,
var_file: Annotated[
str | None,
Option(
"--var-file",
"-f",
help="Load variables from YAML file (overrides config defaults, overridden by --var)",
),
] = None,
dry_run: Annotated[
bool,
Option("--dry-run", help="Preview template generation without writing files"),
] = False,
show_files: Annotated[
bool,
Option(
"--show-files",
help="Display generated file contents in plain text (use with --dry-run)",
),
] = False,
quiet: Annotated[bool, Option("--quiet", "-q", help="Suppress all non-error output")] = False,
) -> None:
"""Generate from template.
Variable precedence chain (lowest to highest):
1. Template defaults (from template.json)
2. Config defaults (from ~/.config/boilerplates/config.yaml)
3. Variable file (from --var-file)
4. CLI overrides (--var flags)
"""
config = GenerationConfig(
id=id,
output=output,
remote=remote,
remote_path=remote_path,
interactive=interactive,
var=var,
var_file=var_file,
name=name,
dry_run=dry_run,
show_files=show_files,
quiet=quiet,
)
return generate_template(self, config)
def validate(
self,
template_id: Annotated[
str | None,
Argument(help="Template ID to validate (omit to validate all templates)"),
] = None,
*,
path: Annotated[
str | None,
Option("--path", help="Path to template directory for validation"),
] = None,
all_templates: Annotated[
bool,
Option("--all", help="Validate all templates in this module (default when no template ID is provided)"),
] = False,
verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
semantic: Annotated[
bool,
Option(
"--semantic/--no-semantic",
help="Enable semantic validation for rendered files",
),
] = True,
matrix: Annotated[
bool,
Option(
"--matrix",
help="Validate all reachable dependency states for a single template",
),
] = False,
kind: Annotated[
bool,
Option(
"--kind",
help="Enable dependency-matrix kind-specific validation when available",
),
] = False,
) -> None:
"""Validate templates for syntax, rendered semantics, and optional dependency matrix checks.
Examples:
# Validate specific template
cli terraform validate cloudflare-dns-record
# Validate all templates
cli terraform validate
# Validate rendered semantic and kind-specific matrix cases
cli terraform validate cloudflare-dns-record --matrix --kind
"""
return validate_templates(
self,
template_id,
path,
ValidationConfig(
verbose=verbose,
semantic=semantic,
matrix=matrix,
kind=kind,
all_templates=all_templates,
kind_validator=self.kind_validator_class(verbose).validate_rendered_files
if kind and self.kind_validator_class
else None,
),
)
def config_get(
self,
var_name: str | None = None,
) -> None:
"""Get default value(s) for this module."""
return config_get(self, var_name)
def config_set(
self,
var_name: str,
value: str | None = None,
) -> None:
"""Set a default value for a variable."""
return config_set(self, var_name, value)
def config_remove(
self,
var_name: Annotated[str, Argument(help="Variable name to remove")],
) -> None:
"""Remove a specific default variable value."""
return config_remove(self, var_name)
def config_clear(
self,
var_name: str | None = None,
force: bool = False,
) -> None:
"""Clear default value(s) for this module."""
return config_clear(self, var_name, force)
def config_list(self) -> None:
"""Display the defaults for this specific module in YAML format."""
return config_list(self)
@classmethod
def register_cli(cls, app: Typer) -> None:
"""Register module commands with the main app."""
logger.debug(f"Registering CLI commands for module '{cls.name}'")
module_instance = cls()
module_app = Typer(help=cls.description)
module_app.command("list")(module_instance.list)
module_app.command("search")(module_instance.search)
module_app.command("show")(module_instance.show)
module_app.command("validate")(module_instance.validate)
module_app.command(
"generate",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)(module_instance.generate)
# Add defaults commands (simplified - only manage default values)
defaults_app = Typer(help="Manage default values for template variables")
defaults_app.command("get", help="Get default value(s)")(module_instance.config_get)
defaults_app.command("set", help="Set a default value")(module_instance.config_set)
defaults_app.command("rm", help="Remove a specific default value")(module_instance.config_remove)
defaults_app.command("clear", help="Clear default value(s)")(module_instance.config_clear)
defaults_app.command("list", help="Display the config for this module in YAML format")(
module_instance.config_list
)
module_app.add_typer(defaults_app, name="defaults")
app.add_typer(
module_app,
name=cls.name,
help=cls.description,
rich_help_panel="Template Commands",
)
logger.info(f"Module '{cls.name}' CLI commands registered")