module.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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 .library import LibraryManager
  8. from .template import Template
  9. from .prompt import PromptHandler
  10. from .args import parse_var_inputs
  11. logger = logging.getLogger(__name__)
  12. console = Console()
  13. class Module(ABC):
  14. """Streamlined base module that auto-detects variables from templates."""
  15. # Required class attributes for subclasses
  16. name = None
  17. description = None
  18. files = None
  19. def __init__(self):
  20. if not all([self.name, self.description, self.files]):
  21. raise ValueError(
  22. f"Module {self.__class__.__name__} must define name, description, and files"
  23. )
  24. logger.info(f"Initializing module '{self.name}'")
  25. logger.debug(f"Module '{self.name}' configuration: files={self.files}, description='{self.description}'")
  26. self.libraries = LibraryManager()
  27. # Initialize variables if the subclass defines _init_variables method
  28. if hasattr(self, '_init_variables'):
  29. logger.debug(f"Module '{self.name}' has variable initialization method")
  30. self._init_variables()
  31. self.metadata = self._build_metadata()
  32. logger.info(f"Module '{self.name}' initialization completed successfully")
  33. def _build_metadata(self) -> Dict[str, Any]:
  34. """Build metadata from class attributes."""
  35. metadata = {}
  36. # Add categories if defined
  37. if hasattr(self, 'categories'):
  38. metadata['categories'] = self.categories
  39. # Add variable metadata if defined
  40. if hasattr(self, 'variable_metadata'):
  41. metadata['variables'] = self.variable_metadata
  42. return metadata
  43. def list(self):
  44. """List all templates."""
  45. logger.debug(f"Listing templates for module '{self.name}'")
  46. templates = self.libraries.load_templates(
  47. self.name,
  48. self.files,
  49. sort_results=True,
  50. module_variables=getattr(self, 'variables_spec', {})
  51. )
  52. if templates:
  53. logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
  54. for template in templates:
  55. console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
  56. else:
  57. logger.info(f"No templates found for module '{self.name}'")
  58. return templates
  59. def show(self, id: str = Argument(..., help="Template ID")):
  60. """Show template details."""
  61. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  62. template = self.libraries.load_template_by_id(
  63. self.name,
  64. self.files,
  65. id,
  66. module_variables=getattr(self, 'variables_spec', {})
  67. )
  68. if not template:
  69. logger.debug(f"Template '{id}' not found in module '{self.name}'")
  70. raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
  71. # Header
  72. version = f" v{template.version}" if template.version else ""
  73. console.print(f"[bold magenta]{template.name} ({template.id}{version})[/bold magenta]")
  74. console.print(f"[dim white]{template.description}[/dim white]\n")
  75. # Metadata (only print if exists)
  76. metadata = [
  77. ("Author", template.author),
  78. ("Date", template.date),
  79. ("Tags", ', '.join(template.tags) if template.tags else None)
  80. ]
  81. for label, value in metadata:
  82. if value:
  83. console.print(f"{label}: [cyan]{value}[/cyan]")
  84. # Variables (show template variables)
  85. if template.variables:
  86. console.print(f"Variables: [cyan]{', '.join(template.variables.get_variable_names())}[/cyan]")
  87. # Content
  88. if template.content:
  89. print(f"\n{template.content}")
  90. def generate(
  91. self,
  92. id: str = Argument(..., help="Template ID"),
  93. out: Optional[Path] = Option(None, "--out", "-o"),
  94. interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
  95. var: Optional[List[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
  96. ctx: Context = None,
  97. ):
  98. """Generate from template.
  99. Supports variable overrides via:
  100. --var KEY=VALUE
  101. --var KEY VALUE
  102. """
  103. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  104. template = self.libraries.load_template_by_id(
  105. self.name,
  106. self.files,
  107. id,
  108. module_variables=getattr(self, 'variables_spec', {})
  109. )
  110. if not template:
  111. logger.error(f"Template '{id}' not found for generation in module '{self.name}'")
  112. raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
  113. # Build variable overrides from Typer-collected options and any extra args
  114. extra_args = []
  115. try:
  116. if ctx is not None and hasattr(ctx, "args"):
  117. extra_args = list(ctx.args)
  118. except Exception:
  119. extra_args = []
  120. cli_overrides = parse_var_inputs(var or [], extra_args)
  121. if cli_overrides:
  122. logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
  123. # Collect variable values interactively if enabled
  124. variable_values = {}
  125. if interactive and template.variables:
  126. prompt_handler = PromptHandler()
  127. # Collect values with sectioned flow
  128. collected_values = prompt_handler.collect_variables(
  129. variables=template.variables,
  130. template_name=template.name,
  131. module_name=self.name,
  132. template_var_order=template.template_var_names,
  133. module_var_order=template.module_var_names,
  134. )
  135. if collected_values:
  136. variable_values.update(collected_values)
  137. logger.info(f"Collected {len(collected_values)} variable values from user input")
  138. # Display summary of collected values
  139. prompt_handler.display_variable_summary(collected_values, template.name)
  140. # Apply CLI overrides last to take highest precedence
  141. if cli_overrides:
  142. variable_values.update(cli_overrides)
  143. # Render template with collected values
  144. try:
  145. rendered_content = template.render(variable_values)
  146. logger.info(f"Successfully rendered template '{id}'")
  147. # Output handling
  148. if out:
  149. # Write to specified file
  150. out.parent.mkdir(parents=True, exist_ok=True)
  151. with open(out, 'w', encoding='utf-8') as f:
  152. f.write(rendered_content)
  153. console.print(f"[green]Generated template to: {out}[/green]")
  154. logger.info(f"Template written to file: {out}")
  155. else:
  156. # Output to stdout
  157. console.print("[bold blue]Generated Template:[/bold blue]")
  158. console.print("─" * 50)
  159. console.print(rendered_content)
  160. logger.info("Template output to stdout")
  161. except Exception as e:
  162. logger.error(f"Error rendering template '{id}': {str(e)}")
  163. console.print(f"[red]Error generating template: {str(e)}[/red]")
  164. raise
  165. def register_cli(self, app: Typer):
  166. """Register module commands with the main app."""
  167. logger.debug(f"Registering CLI commands for module '{self.name}'")
  168. module_app = Typer()
  169. module_app.command()(self.list)
  170. module_app.command()(self.show)
  171. # Allow extra args so we can parse --var overrides ourselves
  172. module_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(self.generate)
  173. app.add_typer(module_app, name=self.name, help=self.description)
  174. logger.info(f"Module '{self.name}' CLI commands registered")