module.py 13 KB

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