module.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. from abc import ABC
  2. from pathlib import Path
  3. from typing import Optional, Dict, Any, List
  4. import logging
  5. from typer import Typer, Option, Argument, Context
  6. from rich.console import Console
  7. from rich.table import Table
  8. from rich.panel import Panel
  9. from rich.rule import Rule
  10. from .library import LibraryManager
  11. from .template import Template
  12. from .prompt import PromptHandler
  13. from .args import parse_var_inputs
  14. from .renderers import render_variable_table, render_template_list_table
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. class Module(ABC):
  18. """Streamlined base module that auto-detects variables from templates."""
  19. # Required class attributes for subclasses
  20. name = None
  21. description = None
  22. files = None
  23. def __init__(self):
  24. if not all([self.name, self.description, self.files]):
  25. raise ValueError(
  26. f"Module {self.__class__.__name__} must define name, description, and files"
  27. )
  28. logger.info(f"Initializing module '{self.name}'")
  29. logger.debug(f"Module '{self.name}' configuration: files={self.files}, description='{self.description}'")
  30. self.libraries = LibraryManager()
  31. # Initialize variables if the subclass defines _init_variables method
  32. if hasattr(self, '_init_variables'):
  33. logger.debug(f"Module '{self.name}' has variable initialization method")
  34. self._init_variables()
  35. logger.info(f"Module '{self.name}' initialization completed successfully")
  36. def list(self):
  37. """List all templates."""
  38. logger.debug(f"Listing templates for module '{self.name}'")
  39. templates = []
  40. module_sections = getattr(self, 'variable_sections', {})
  41. entries = self.libraries.find(self.name, self.files, sort_results=True)
  42. for template_dir, library_name in entries:
  43. template = self._load_template_from_dir(template_dir, library_name, module_sections)
  44. if template:
  45. templates.append(template)
  46. if templates:
  47. logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
  48. table = render_template_list_table(templates, self.name, include_library=False)
  49. console.print(table)
  50. else:
  51. logger.info(f"No templates found for module '{self.name}'")
  52. return templates
  53. def show(
  54. self,
  55. id: str,
  56. show_content: bool = False,
  57. ):
  58. """Show template details."""
  59. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  60. template = self._load_template_by_id(id)
  61. header_title = template.name or template.id
  62. subtitle_parts = [template.id]
  63. if template.version:
  64. subtitle_parts.append(f"v{template.version}")
  65. if template.library:
  66. subtitle_parts.append(f"library: {template.library}")
  67. subtitle = " • ".join(subtitle_parts)
  68. description = template.description or "No description available"
  69. console.print(Panel(description, title=header_title, subtitle=subtitle, border_style="magenta"))
  70. metadata_table = Table.grid(padding=(0, 2))
  71. metadata_table.add_column(style="dim", justify="right")
  72. metadata_table.add_column(style="white")
  73. metadata_table.add_row("Author", template.author or "-")
  74. metadata_table.add_row("Date", template.date or "-")
  75. metadata_table.add_row("Tags", ", ".join(template.tags) if template.tags else "-")
  76. metadata_table.add_row("Files", ", ".join(template.files) if template.files else template.file_path.name)
  77. console.print(Panel(metadata_table, title="Details", border_style="cyan", expand=False))
  78. if template.variables:
  79. console.print(render_variable_table(template.variables, sections=template.variable_sections))
  80. if show_content and template.content:
  81. console.print(Rule("Template Content"))
  82. console.print(template.content)
  83. def generate(
  84. self,
  85. id: str = Argument(..., help="Template ID"),
  86. out: Optional[Path] = Option(None, "--out", "-o"),
  87. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  88. var: Optional[List[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  89. ctx: Context = None,
  90. ):
  91. """Generate from template.
  92. Supports variable overrides via:
  93. --var KEY=VALUE
  94. --var KEY VALUE
  95. """
  96. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  97. template = self._load_template_by_id(id)
  98. # Build variable overrides from Typer-collected options and any extra args
  99. extra_args = []
  100. try:
  101. if ctx is not None and hasattr(ctx, "args"):
  102. extra_args = list(ctx.args)
  103. except Exception:
  104. extra_args = []
  105. cli_overrides = parse_var_inputs(var or [], extra_args)
  106. if cli_overrides:
  107. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  108. # Collect variable values interactively if enabled
  109. variable_values = {}
  110. if interactive and template.variables:
  111. prompt_handler = PromptHandler()
  112. # Collect values with sectioned flow
  113. collected_values = prompt_handler.collect_variables(
  114. variables=template.variables,
  115. template_name=template.name,
  116. module_name=self.name,
  117. template_var_order=template.template_var_names,
  118. module_var_order=template.module_var_names,
  119. sections=template.variable_sections,
  120. )
  121. if collected_values:
  122. variable_values.update(collected_values)
  123. logger.info(f"Collected {len(collected_values)} variable values from user input")
  124. # Display summary of collected values
  125. prompt_handler.display_variable_summary(collected_values, template.name)
  126. # Apply CLI overrides last to take highest precedence
  127. if cli_overrides:
  128. variable_values.update(cli_overrides)
  129. # Render template with collected values
  130. try:
  131. variable_values = self._apply_common_defaults(template, variable_values)
  132. rendered_content = template.render(variable_values)
  133. logger.info(f"Successfully rendered template '{id}'")
  134. # Output handling
  135. if out:
  136. # Write to specified file
  137. out.parent.mkdir(parents=True, exist_ok=True)
  138. with open(out, 'w', encoding='utf-8') as f:
  139. f.write(rendered_content)
  140. console.print(f"[green]Generated template to: {out}[/green]")
  141. logger.info(f"Template written to file: {out}")
  142. else:
  143. # Output to stdout
  144. console.print("[bold blue]Generated Template:[/bold blue]")
  145. console.print("─" * 50)
  146. console.print(rendered_content)
  147. logger.info("Template output to stdout")
  148. except Exception as e:
  149. logger.error(f"Error rendering template '{id}': {str(e)}")
  150. console.print(f"[red]Error generating template: {str(e)}[/red]")
  151. raise
  152. @classmethod
  153. def register_cli(cls, app: Typer):
  154. """Register module commands with the main app using lazy instantiation."""
  155. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  156. def _load_module() -> "Module":
  157. logger.debug(f"Lazily instantiating module '{cls.name}'")
  158. return cls()
  159. def _invoke(method_name: str, *args, **kwargs):
  160. module = _load_module()
  161. method = getattr(module, method_name)
  162. return method(*args, **kwargs)
  163. module_app = Typer()
  164. @module_app.command()
  165. def list():
  166. return _invoke("list")
  167. @module_app.command()
  168. def show(
  169. id: str = Argument(..., help="Template ID"),
  170. show_content: bool = Option(
  171. False,
  172. "--show-content/--hide-content",
  173. "-c/-C",
  174. help="Display full template content",
  175. ),
  176. ):
  177. return _invoke("show", id, show_content)
  178. # Allow extra args so we can parse --var overrides ourselves
  179. @module_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
  180. def generate(
  181. id: str = Argument(..., help="Template ID"),
  182. out: Optional[Path] = Option(None, "--out", "-o"),
  183. interactive: bool = Option(
  184. True,
  185. "--interactive/--no-interactive",
  186. "-i/-n",
  187. help="Enable interactive prompting for variables",
  188. ),
  189. var: Optional[List[str]] = Option(
  190. None,
  191. "--var",
  192. "-v",
  193. help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE",
  194. ),
  195. ctx: Context = None,
  196. ):
  197. return _invoke(
  198. "generate",
  199. id,
  200. out,
  201. interactive,
  202. var,
  203. ctx,
  204. )
  205. app.add_typer(module_app, name=cls.name, help=cls.description)
  206. logger.info(f"Module '{cls.name}' CLI commands registered")
  207. def _apply_common_defaults(self, template: Template, values: Dict[str, Any]) -> Dict[str, Any]:
  208. """Ensure core variables have sensible defaults for non-interactive runs."""
  209. defaults = {}
  210. def needs_value(key: str) -> bool:
  211. if key not in values:
  212. return True
  213. current = values[key]
  214. return current is None or (isinstance(current, str) and current.strip() == "")
  215. if template.variables.get_variable("service_name") and needs_value("service_name"):
  216. defaults["service_name"] = template.id
  217. if template.variables.get_variable("container_name") and needs_value("container_name"):
  218. defaults["container_name"] = template.id
  219. if template.variables.get_variable("container_timezone") and needs_value("container_timezone"):
  220. defaults["container_timezone"] = "UTC"
  221. if defaults:
  222. logger.debug(f"Applying common defaults: {defaults}")
  223. for key, value in defaults.items():
  224. values[key] = value
  225. return values
  226. def _load_template_by_id(self, template_id: str) -> Template:
  227. result = self.libraries.find_by_id(self.name, self.files, template_id)
  228. if not result:
  229. logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
  230. raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
  231. template_dir, library_name = result
  232. template = self._load_template_from_dir(
  233. template_dir,
  234. library_name,
  235. getattr(self, 'variable_sections', {}),
  236. )
  237. if not template:
  238. raise FileNotFoundError(f"Template file for '{template_id}' not found in module '{self.name}'")
  239. return template
  240. def _load_template_from_dir(
  241. self,
  242. template_dir: Path,
  243. library_name: str,
  244. module_sections: Dict[str, Any],
  245. ) -> Optional[Template]:
  246. template_file = self._resolve_template_file(template_dir)
  247. if not template_file:
  248. logger.warning(f"Template directory '{template_dir}' missing expected files {self.files}")
  249. return None
  250. try:
  251. template = Template.from_file(
  252. template_file,
  253. module_sections=module_sections,
  254. library_name=library_name,
  255. )
  256. return template
  257. except Exception as exc:
  258. logger.error(f"Failed to load template from {template_file}: {exc}")
  259. return None
  260. def _resolve_template_file(self, template_dir: Path) -> Optional[Path]:
  261. for file_name in self.files:
  262. candidate = template_dir / file_name
  263. if candidate.exists():
  264. return candidate
  265. return None