module.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. from __future__ import annotations
  2. import logging
  3. from abc import ABC
  4. from pathlib import Path
  5. from typing import Any, Dict, List, Optional
  6. from rich.console import Console
  7. from rich.panel import Panel
  8. from rich.prompt import Prompt
  9. from rich.table import Table
  10. from rich.tree import Tree
  11. from typer import Argument, Context, Option, Typer
  12. from .library import LibraryManager
  13. from .prompt import PromptHandler
  14. from .template import Template
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. # -------------------------------
  18. # SECTION: Helper Functions
  19. # -------------------------------
  20. def parse_var_inputs(var_options: list[str], extra_args: list[str]) -> dict[str, Any]:
  21. """Parse variable inputs from --var options and extra args.
  22. Supports formats:
  23. --var KEY=VALUE
  24. --var KEY VALUE
  25. Args:
  26. var_options: List of variable options from CLI
  27. extra_args: Additional arguments that may contain values
  28. Returns:
  29. Dictionary of parsed variables
  30. """
  31. variables = {}
  32. # Parse --var KEY=VALUE format
  33. for var_option in var_options:
  34. if '=' in var_option:
  35. key, value = var_option.split('=', 1)
  36. variables[key] = value
  37. else:
  38. # --var KEY VALUE format - value should be in extra_args
  39. if extra_args:
  40. variables[var_option] = extra_args.pop(0)
  41. else:
  42. logger.warning(f"No value provided for variable '{var_option}'")
  43. return variables
  44. # !SECTION
  45. # ---------------------
  46. # SECTION: Module Class
  47. # ---------------------
  48. class Module(ABC):
  49. """Streamlined base module that auto-detects variables from templates."""
  50. name: str | None = None
  51. description: str | None = None
  52. def __init__(self) -> None:
  53. if not all([self.name, self.description]):
  54. raise ValueError(
  55. f"Module {self.__class__.__name__} must define name and description"
  56. )
  57. logger.info(f"Initializing module '{self.name}'")
  58. logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
  59. self.libraries = LibraryManager()
  60. # --------------------------
  61. # SECTION: Public Commands
  62. # --------------------------
  63. def list(self) -> list[Template]:
  64. """List all templates."""
  65. logger.debug(f"Listing templates for module '{self.name}'")
  66. templates = []
  67. entries = self.libraries.find(self.name, sort_results=True)
  68. for template_dir, library_name in entries:
  69. try:
  70. template = Template(template_dir, library_name=library_name)
  71. templates.append(template)
  72. except Exception as exc:
  73. logger.error(f"Failed to load template from {template_dir}: {exc}")
  74. continue
  75. if templates:
  76. logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
  77. table = Table(title=f"{self.name.capitalize()} templates")
  78. table.add_column("ID", style="bold", no_wrap=True)
  79. table.add_column("Name")
  80. table.add_column("Description")
  81. table.add_column("Version", no_wrap=True)
  82. table.add_column("Tags")
  83. table.add_column("Library", no_wrap=True)
  84. for template in templates:
  85. name = template.metadata.name or 'Unnamed Template'
  86. desc = template.metadata.description or 'No description available'
  87. version = template.metadata.version or ''
  88. tags_list = template.metadata.tags or []
  89. tags = ", ".join(tags_list) if isinstance(tags_list, list) else str(tags_list)
  90. library = template.metadata.library or ''
  91. table.add_row(template.id, name, desc, version, tags, library)
  92. console.print(table)
  93. else:
  94. logger.info(f"No templates found for module '{self.name}'")
  95. return templates
  96. def show(
  97. self,
  98. id: str,
  99. show_content: bool = False,
  100. ) -> None:
  101. """Show template details."""
  102. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  103. template = self._load_template_by_id(id)
  104. if not template:
  105. logger.warning(f"Template '{id}' not found in module '{self.name}'")
  106. console.print(f"[red]Template '{id}' not found in module '{self.name}'[/red]")
  107. return
  108. self._display_template_details(template, id)
  109. def generate(
  110. self,
  111. id: str = Argument(..., help="Template ID"),
  112. out: Optional[Path] = Option(None, "--out", "-o", help="Output directory"),
  113. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  114. var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  115. ctx: Context = None,
  116. ) -> None:
  117. """Generate from template."""
  118. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  119. template = self._load_template_by_id(id)
  120. extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
  121. cli_overrides = parse_var_inputs(var or [], extra_args)
  122. if cli_overrides:
  123. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  124. if template.variables:
  125. successful_overrides = template.variables.apply_overrides(cli_overrides, " -> cli")
  126. if successful_overrides:
  127. logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
  128. self._display_template_details(template, id)
  129. console.print()
  130. variable_values = {}
  131. if interactive and template.variables:
  132. prompt_handler = PromptHandler()
  133. collected_values = prompt_handler.collect_variables(template.variables)
  134. if collected_values:
  135. variable_values.update(collected_values)
  136. logger.info(f"Collected {len(collected_values)} variable values from user input")
  137. if template.variables:
  138. variable_values.update(template.variables.get_all_values())
  139. try:
  140. # Validate all variables before rendering
  141. if template.variables:
  142. template.variables.validate_all()
  143. rendered_files = template.render(variable_values)
  144. logger.info(f"Successfully rendered template '{id}'")
  145. output_dir = out
  146. if not output_dir:
  147. output_dir_str = Prompt.ask("Enter output directory", default=".")
  148. output_dir = Path(output_dir_str)
  149. for file_path, content in rendered_files.items():
  150. full_path = output_dir / file_path
  151. full_path.parent.mkdir(parents=True, exist_ok=True)
  152. with open(full_path, 'w', encoding='utf-8') as f:
  153. f.write(content)
  154. console.print(f"[green]Generated file: {full_path}[/green]")
  155. logger.info(f"Template written to directory: {output_dir}")
  156. except Exception as e:
  157. logger.error(f"Error rendering template '{id}': {e}")
  158. console.print(f"[red]Error generating template: {e}[/red]")
  159. raise
  160. # !SECTION
  161. # ------------------------------
  162. # SECTION: CLI Registration
  163. # ------------------------------
  164. @classmethod
  165. def register_cli(cls, app: Typer) -> None:
  166. """Register module commands with the main app."""
  167. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  168. module_instance = cls()
  169. module_app = Typer(help=cls.description)
  170. module_app.command("list")(module_instance.list)
  171. module_app.command("show")(module_instance.show)
  172. module_app.command(
  173. "generate",
  174. context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
  175. )(module_instance.generate)
  176. app.add_typer(module_app, name=cls.name, help=cls.description)
  177. logger.info(f"Module '{cls.name}' CLI commands registered")
  178. # !SECTION
  179. # --------------------------
  180. # SECTION: Private Methods
  181. # --------------------------
  182. def _load_template_by_id(self, template_id: str) -> Template:
  183. result = self.libraries.find_by_id(self.name, template_id)
  184. if not result:
  185. logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
  186. raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
  187. template_dir, library_name = result
  188. try:
  189. return Template(template_dir, library_name=library_name)
  190. except (ValueError, FileNotFoundError) as exc:
  191. raise FileNotFoundError(f"Template '{template_id}' validation failed in module '{self.name}'") from exc
  192. except Exception as exc:
  193. logger.error(f"Failed to load template from {template_dir}: {exc}")
  194. raise FileNotFoundError(f"Template '{template_id}' could not be loaded in module '{self.name}'") from exc
  195. def _display_template_details(self, template: Template, template_id: str) -> None:
  196. """Display template information panel and variables table."""
  197. # Print the main panel
  198. console.print(Panel(
  199. f"[bold]{template.metadata.name or 'Unnamed Template'}[/bold]\n\n{template.metadata.description or 'No description available'}",
  200. title=f"Template: {template_id}",
  201. subtitle=f"Module: {self.name}"
  202. ))
  203. # Build the file structure tree
  204. file_tree = Tree("[bold blue]Template File Structure:[/bold blue]")
  205. # Create a dictionary to hold the tree nodes for directories
  206. # This will allow us to build a proper tree structure
  207. tree_nodes = {Path('.'): file_tree} # Root of the template directory
  208. for template_file in sorted(template.template_files, key=lambda f: f.relative_path):
  209. parts = template_file.relative_path.parts
  210. current_path = Path('.')
  211. current_node = file_tree
  212. # Build the directory path in the tree
  213. for part in parts[:-1]: # Iterate through directories
  214. current_path = current_path / part
  215. if current_path not in tree_nodes:
  216. new_node = current_node.add(f"\uf07b [bold blue]{part}[/bold blue]") # Folder icon
  217. tree_nodes[current_path] = new_node
  218. current_node = new_node
  219. else:
  220. current_node = tree_nodes[current_path]
  221. # Add the file to the appropriate directory node
  222. if template_file.file_type == 'j2':
  223. current_node.add(f"[green]\ue235 {template_file.relative_path.name}[/green]") # Jinja2 file icon
  224. elif template_file.file_type == 'static':
  225. current_node.add(f"[yellow]\uf15b {template_file.relative_path.name}[/yellow]") # Generic file icon
  226. # Print the file tree separately if it has content
  227. if file_tree.children: # Check if any files were added to the branches
  228. console.print() # Add spacing
  229. console.print(file_tree) # Print the Tree object directly
  230. if template.variables and template.variables._set:
  231. console.print() # Add spacing
  232. # Create variables table
  233. variables_table = Table(title="Template Variables", show_header=True, header_style="bold blue")
  234. variables_table.add_column("Variable", style="cyan", no_wrap=True)
  235. variables_table.add_column("Type", style="magenta")
  236. variables_table.add_column("Default", style="green")
  237. variables_table.add_column("Description", style="white")
  238. variables_table.add_column("Origin", style="yellow")
  239. # Add variables grouped by section
  240. first_section = True
  241. for section_key, section in template.variables._set.items():
  242. if section.variables:
  243. # Add spacing between sections (except before first section)
  244. if not first_section:
  245. variables_table.add_row("", "", "", "", "", style="dim")
  246. first_section = False
  247. # Check if section should be dimmed (toggle is False)
  248. is_dimmed = False
  249. if section.toggle:
  250. toggle_var = section.variables.get(section.toggle)
  251. if toggle_var:
  252. # Get the actual typed value and check if it's falsy
  253. try:
  254. toggle_value = toggle_var.get_typed_value()
  255. if not toggle_value:
  256. is_dimmed = True
  257. except Exception as e:
  258. # Fallback to raw value check
  259. if not toggle_var.value:
  260. is_dimmed = True
  261. # Add section header row with proper styling
  262. disabled_text = " (disabled)" if is_dimmed else ""
  263. required_text = " [yellow](required)[/yellow]" if section.required else ""
  264. if is_dimmed:
  265. # Use Rich markup for dimmed bold text
  266. header_text = f"[bold dim]{section.title}{required_text}{disabled_text}[/bold dim]"
  267. else:
  268. # Use Rich markup for bold text
  269. header_text = f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
  270. variables_table.add_row(
  271. header_text,
  272. "", "", "", ""
  273. )
  274. # Add variables in this section
  275. for var_name, variable in section.variables.items():
  276. # Apply dim style to ALL variables if section toggle is False
  277. row_style = "dim" if is_dimmed else None
  278. # Format default value
  279. default_val = str(variable.value) if variable.value is not None else ""
  280. if len(default_val) > 30:
  281. default_val = default_val[:27] + "..."
  282. variables_table.add_row(
  283. f" {var_name}",
  284. variable.type or "str",
  285. default_val,
  286. variable.description or "",
  287. variable.origin or "unknown",
  288. style=row_style
  289. )
  290. console.print(variables_table)
  291. # !SECTION