| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- """
- Compose module commands and functionality.
- Manage Compose configurations and services and template operations.
- """
- import typer
- from pathlib import Path
- from rich.console import Console
- from rich.table import Table
- 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)
- bp = next((b for b in bps if b.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 clever way
- table = Table(title=f"🐳 Boilerplate: {bp.name}", title_style="bold blue")
- table.add_column("Field", style="cyan", no_wrap=True)
- table.add_column("Value", style="green")
- info = bp.to_dict()
- for key, value in info.items():
- if isinstance(value, List):
- value = ", ".join(str(v) for v in value)
- table.add_row(key.title(), str(value))
- self.console.print(table)
- # Show the content of the boilerplate file in a cleaner form
- from rich.panel import Panel
- from rich.syntax import Syntax
- self.console.print() # Add spacing
- # Use syntax highlighting for YAML files
- syntax = Syntax(bp.content, "yaml", 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)
- bp = next((b for b in bps if b.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()
- matched_sets, used_vars = cv.determine_variable_sets(bp.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)
- template_defaults = cv.extract_template_defaults(bp.content)
- 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)
- template = env.from_string(bp.content)
- rendered = template.render(**values_dict)
- # If --out not provided, print to console; else write to file
- if out is None:
- from rich.panel import Panel
- from rich.syntax import Syntax
- syntax = Syntax(rendered, "yaml", theme="monokai", line_numbers=False, word_wrap=True)
- panel = Panel(syntax, title=f"{bp.name}", border_style="green", padding=(1,2))
- self.console.print(panel)
- 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]")
|