| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- """
- Compose module commands and functionality.
- Manage Compose configurations and services and template operations.
- """
- import re
- import typer
- from pathlib import Path
- from rich.console import Console
- from rich.table import Table
- from rich.syntax import Syntax
- from typing import List, Optional, Set, Dict, Any
- from ...core.command import BaseModule
- from ...core.helpers import find_boilerplates
- from .variables import ComposeVariables
- class ComposeModule(BaseModule):
- """Module for managing compose boilerplates."""
- compose_filenames = ["compose.yaml", "docker-compose.yaml", "compose.yml", "docker-compose.yml"]
- library_path = Path(__file__).parent.parent.parent.parent / "library" / "compose"
- def __init__(self):
- super().__init__(name="compose", icon="🐳", description="Manage Compose Templates and Configurations")
- def get_valid_variables(self) -> Set[str]:
- """Get the set of valid variable names for the compose module."""
- variables = ComposeVariables()
- return set(variables._declared.keys())
-
- def _get_variable_details(self) -> Dict[str, Dict[str, Any]]:
- """Get detailed information about variables for display."""
- variables = ComposeVariables()
- details = {}
- for var_name, (set_name, var_meta) in variables._declared.items():
- details[var_name] = {
- 'set': set_name,
- 'type': var_meta.get('type', 'str'),
- 'display_name': var_meta.get('display_name', var_name),
- 'default': var_meta.get('default'),
- 'prompt': var_meta.get('prompt', '')
- }
- return details
- def _add_module_commands(self, app: typer.Typer) -> None:
- """Add Module-specific commands to the app."""
- @app.command("list", help="List all compose boilerplates")
- def list():
- """List all compose boilerplates from library/compose directory."""
- bps = find_boilerplates(self.library_path, self.compose_filenames)
- if not bps:
- self.console.print("[yellow]No compose boilerplates found.[/yellow]")
- return
- table = Table(title="🐳 Available Compose Boilerplates", title_style="bold blue")
- table.add_column("Name", style="cyan", no_wrap=True)
- table.add_column("Module", style="magenta")
- table.add_column("Path", style="green")
- table.add_column("Size", justify="right", style="yellow")
- table.add_column("Description", style="dim")
- for bp in bps:
- if bp.size < 1024:
- size_str = f"{bp.size} B"
- elif bp.size < 1024 * 1024:
- size_str = f"{bp.size // 1024} KB"
- else:
- size_str = f"{bp.size // (1024 * 1024)} MB"
- table.add_row(
- bp.name,
- bp.module,
- str(bp.file_path.relative_to(self.library_path)),
- size_str,
- bp.description[:50] + "..." if len(bp.description) > 50 else bp.description
- )
- self.console.print(table)
- @app.command("show", help="Show details about a compose boilerplate")
- def show(name: str, raw: bool = typer.Option(False, "--raw", help="Output only the raw boilerplate content")):
- """Show details about a compose boilerplate by name."""
- bps = find_boilerplates(self.library_path, self.compose_filenames)
- # Match by directory name (parent folder of the compose file) instead of frontmatter 'name'
- bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
- if not bp:
- self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
- return
- if raw:
- # Output only the raw boilerplate content
- print(bp.content)
- return
- # Print frontmatter info in a clean, readable format
- from rich.text import Text
- from rich.console import Group
-
- info = bp.to_dict()
-
- # Create a clean header
- header = Text()
- header.append("🐳 Boilerplate: ", style="bold")
- header.append(f"{info['name']}", style="bold blue")
- header.append(f" ({info['version']})", style="magenta")
- header.append("\n", style="bold")
- header.append(f"{info['description']}", style="dim white")
-
- # Create metadata section with clean formatting
- metadata = Text()
- metadata.append("\nDetails:\n", style="bold cyan")
- metadata.append("─" * 40 + "\n", style="dim cyan")
-
- # Format each field with consistent styling
- fields = [
- ("Tags", ", ".join(info['tags']), "cyan"),
- ("Author", info['author'], "dim white"),
- ("Date", info['date'], "dim white"),
- ("Size", info['size'], "dim white"),
- ("Path", info['path'], "dim white")
- ]
-
- for label, value, color in fields:
- metadata.append(f"{label}: ")
- metadata.append(f"{value}\n", style=color)
-
- # Handle files list if present
- if info['files'] and len(info['files']) > 0:
- metadata.append(" Files: ")
- files_str = ", ".join(info['files'][:3]) # Show first 3
- if len(info['files']) > 3:
- files_str += f" ... and {len(info['files']) - 3} more"
- metadata.append(f"{files_str}\n", style="green")
-
- # Display everything as a group
- display_group = Group(header, metadata)
- self.console.print(display_group)
- # Show the content of the boilerplate file in a cleaner form
- from rich.panel import Panel
- from rich.syntax import Syntax
- # Detect if content contains Jinja2 templating
- has_jinja = bool(re.search(r'\{\{.*\}\}|\{\%.*\%\}|\{\#.*\#\}', bp.content))
-
- # Use appropriate lexer based on content
- # Use yaml+jinja for combined YAML and Jinja2 highlighting when Jinja2 is present
- lexer = "yaml+jinja" if has_jinja else "yaml"
- syntax = Syntax(bp.content, lexer, theme="monokai", line_numbers=True, word_wrap=True)
- panel = Panel(syntax, title=f"{bp.file_path.name}", border_style="blue", padding=(1,2))
- self.console.print(panel)
- @app.command("search", help="Search compose boilerplates")
- def search(query: str):
- pass
- @app.command("generate", help="Generate a compose file from a boilerplate and write to --out")
- def generate(
- name: str,
- out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output path to write rendered boilerplate (prints to stdout when omitted)"),
- values_file: Optional[Path] = typer.Option(None, "--values-file", "-f", help="Load values from YAML/JSON file"),
- values: Optional[List[str]] = typer.Option(None, "--values", help="Set values (format: key=value)")
- ):
- """Render a compose boilerplate interactively and write output to --out."""
- bps = find_boilerplates(self.library_path, self.compose_filenames)
- # Match by directory name (parent folder of the compose file) instead of frontmatter 'name'
- bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
- if not bp:
- self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
- raise typer.Exit(code=1)
- cv = ComposeVariables()
- # Remove any in-template `{% variables %} ... {% endvariables %}` block
- # before asking Jinja2 to parse/render the template. This block is
- # used only to provide metadata overrides and is not valid Jinja2
- # syntax for the default parser (unknown tag -> TemplateSyntaxError).
- import re
- cleaned_content = re.sub(r"\{%\s*variables\s*%\}(.+?)\{%\s*endvariables\s*%\}\n?", "", bp.content, flags=re.S)
- matched_sets, used_vars = cv.determine_variable_sets(cleaned_content)
- # If there are no detected variable sets but there are used vars, we still
- # need to prompt for the used variables. Lazy-import jinja2 only when
- # rendering is required so module import doesn't fail when Jinja2 is missing.
- if not used_vars:
- rendered = bp.content
- else:
- try:
- import jinja2
- except Exception:
- typer.secho("Jinja2 is required to render templates. Install it and retry.", fg=typer.colors.RED)
- raise typer.Exit(code=2)
- # Use the cleaned content for defaults and rendering, but extract
- # overrides from the original content (which may contain the
- # variables block).
- template_defaults = cv.extract_template_defaults(cleaned_content)
- # Validate Jinja2 template syntax before proceeding. Parsing the
- # template will surface syntax errors (unclosed blocks, invalid
- # tags, etc.) early and allow us to abort with a helpful message.
- try:
- env_for_validation = jinja2.Environment(loader=jinja2.BaseLoader())
- env_for_validation.parse(cleaned_content)
- except jinja2.exceptions.TemplateSyntaxError as e:
- # Show file path (if available) and error details, then exit.
- self.console.print(f"[red]Template syntax error in '{bp.file_path}': {e.message} (line {e.lineno})[/red]")
- raise typer.Exit(code=2)
- except Exception as e:
- # Generic parse failure
- self.console.print(f"[red]Failed to parse template '{bp.file_path}': {e}[/red]")
- raise typer.Exit(code=2)
- # Extract variable metadata overrides from a {% variables %} block
- try:
- meta_overrides = cv.extract_variable_meta_overrides(bp.content)
- # Merge overrides into declared metadata so PromptHandler will pick them up
- for var_name, overrides in meta_overrides.items():
- if var_name in cv._declared and isinstance(overrides, dict):
- existing = cv._declared[var_name][1]
- # shallow merge
- existing.update(overrides)
- except Exception:
- meta_overrides = {}
- used_subscripts = cv.find_used_subscript_keys(bp.content)
-
- # Load values from file if specified
- file_values = {}
- if values_file:
- if not values_file.exists():
- self.console.print(f"[red]Values file '{values_file}' not found.[/red]")
- raise typer.Exit(code=1)
-
- try:
- import yaml
- with open(values_file, 'r', encoding='utf-8') as f:
- if values_file.suffix.lower() in ['.yaml', '.yml']:
- file_values = yaml.safe_load(f) or {}
- elif values_file.suffix.lower() == '.json':
- import json
- file_values = json.load(f)
- else:
- self.console.print(f"[red]Unsupported file format '{values_file.suffix}'. Use .yaml, .yml, or .json[/red]")
- raise typer.Exit(code=1)
- self.console.print(f"[dim]Loaded values from {values_file}[/dim]")
- except Exception as e:
- self.console.print(f"[red]Failed to load values from {values_file}: {e}[/red]")
- raise typer.Exit(code=1)
-
- # Parse command-line values
- cli_values = {}
- if values:
- for value_pair in values:
- if '=' not in value_pair:
- self.console.print(f"[red]Invalid value format '{value_pair}'. Use key=value format.[/red]")
- raise typer.Exit(code=1)
- key, val = value_pair.split('=', 1)
- # Try to parse as JSON for complex values
- try:
- import json
- cli_values[key] = json.loads(val)
- except json.JSONDecodeError:
- cli_values[key] = val
- except Exception:
- cli_values[key] = val
-
- # Override template defaults with configured values
- from ...core.config import ConfigManager
- config_manager = ConfigManager(self.name)
- config_values = config_manager.list_all()
-
- # Merge values in order of precedence: template defaults <- config <- file <- CLI
- for key, config_value in config_values.items():
- template_defaults[key] = config_value
-
- for key, file_value in file_values.items():
- template_defaults[key] = file_value
-
- for key, cli_value in cli_values.items():
- template_defaults[key] = cli_value
-
- values_dict = cv.collect_values(used_vars, template_defaults, used_subscripts)
- # Enable Jinja2 whitespace control so that block tags like
- # {% if %} don't leave an extra newline in the rendered result.
- env = jinja2.Environment(loader=jinja2.BaseLoader(), trim_blocks=True, lstrip_blocks=True)
- try:
- template = env.from_string(cleaned_content)
- except jinja2.exceptions.TemplateSyntaxError as e:
- self.console.print(f"[red]Template syntax error in '{bp.file_path}': {e.message} (line {e.lineno})[/red]")
- raise typer.Exit(code=2)
- except Exception as e:
- self.console.print(f"[red]Failed to compile template '{bp.file_path}': {e}[/red]")
- raise typer.Exit(code=2)
- try:
- rendered = template.render(**values_dict)
- except jinja2.exceptions.TemplateError as e:
- # Catch runtime/template errors (undefined variables, etc.)
- self.console.print(f"[red]Template rendering error for '{bp.file_path}': {e}[/red]")
- raise typer.Exit(code=2)
- except Exception as e:
- self.console.print(f"[red]Unexpected error while rendering '{bp.file_path}': {e}[/red]")
- raise typer.Exit(code=2)
- # If --out not provided, print to console; else write to file
- if out is None:
- # Print a subtle rule and a small header, then the highlighted YAML
- self.console.print(f"\n\nGenerated Boilerplate for [bold cyan]{bp.name}[/bold cyan]\n")
- syntax = Syntax(rendered, "yaml", theme="monokai", line_numbers=False, word_wrap=True)
- self.console.print(syntax)
- else:
- # Ensure parent directory exists
- out_parent = out.parent
- if not out_parent.exists():
- out_parent.mkdir(parents=True, exist_ok=True)
- out.write_text(rendered, encoding="utf-8")
- self.console.print(f"[green]Rendered boilerplate written to {out}[/green]")
|