module.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. from abc import ABC
  2. from pathlib import Path
  3. from typing import List, Optional, Dict, Any
  4. import logging
  5. import yaml
  6. from typer import Typer, Option, Argument
  7. from rich.console import Console
  8. from .config import get_config
  9. from .exceptions import TemplateNotFoundError, TemplateValidationError
  10. from .library import LibraryManager
  11. from .prompt import SimplifiedPromptHandler
  12. logger = logging.getLogger('boilerplates')
  13. console = Console() # Single shared console instance
  14. class Module(ABC):
  15. """Streamlined base module that auto-detects variables from templates."""
  16. # Required class attributes for subclasses
  17. name = None
  18. description = None
  19. files = None
  20. def __init__(self):
  21. if not all([self.name, self.description, self.files]):
  22. raise ValueError(
  23. f"Module {self.__class__.__name__} must define name, description, and files"
  24. )
  25. self.libraries = LibraryManager()
  26. self.metadata = self._load_metadata()
  27. def _load_metadata(self) -> Dict[str, Any]:
  28. """Load module metadata from .meta.yaml file if it exists."""
  29. import inspect
  30. # Get the path to the actual module file
  31. module_path = Path(inspect.getfile(self.__class__))
  32. meta_file = module_path.with_suffix('.meta.yaml')
  33. if meta_file.exists():
  34. try:
  35. with open(meta_file, 'r') as f:
  36. return yaml.safe_load(f) or {}
  37. except Exception as e:
  38. logger.debug(f"Failed to load metadata for {self.name}: {e}")
  39. return {}
  40. def list(self):
  41. """List all templates."""
  42. templates = self.libraries.find(self.name, self.files, sorted=True)
  43. for template in templates:
  44. console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
  45. return templates
  46. def show(self, id: str = Argument(..., help="Template ID")):
  47. """Show template details."""
  48. logger.debug(f"Showing template: {id}")
  49. template = self._get_template(id)
  50. if not template:
  51. return
  52. # Header
  53. version = f" v{template.version}" if template.version else ""
  54. console.print(f"[bold magenta]{template.name} ({template.id}{version})[/bold magenta]")
  55. console.print(f"[dim white]{template.description}[/dim white]\n")
  56. # Metadata (only print if exists)
  57. metadata = [
  58. ("Author", template.author),
  59. ("Date", template.date),
  60. ("Tags", ', '.join(template.tags) if template.tags else None)
  61. ]
  62. for label, value in metadata:
  63. if value:
  64. console.print(f"{label}: [cyan]{value}[/cyan]")
  65. # Variables
  66. if template.vars:
  67. console.print(f"Variables: [cyan]{', '.join(sorted(template.vars))}[/cyan]")
  68. # Content
  69. if template.content:
  70. console.print(f"\n{template.content}")
  71. def _get_template(self, template_id: str, raise_on_missing: bool = False):
  72. """Get template by ID with unified error handling."""
  73. template = self.libraries.find_by_id(self.name, self.files, template_id)
  74. if not template:
  75. logger.error(f"Template '{template_id}' not found")
  76. if raise_on_missing:
  77. raise TemplateNotFoundError(template_id, self.name)
  78. console.print(f"[red]Template '{template_id}' not found[/red]")
  79. return template
  80. def generate(
  81. self,
  82. id: str = Argument(..., help="Template ID"),
  83. out: Optional[Path] = Option(None, "--out", "-o")
  84. ):
  85. """Generate from template."""
  86. logger.debug(f"Generating template: {id}")
  87. template = self._get_template(id, raise_on_missing=True)
  88. # Validate template (will raise TemplateValidationError if validation fails)
  89. self._validate_template(template, id)
  90. # Process variables and render
  91. values = self._process_variables(template)
  92. try:
  93. content = template.render(values)
  94. except Exception as e:
  95. logger.error(f"Failed to render template: {e}")
  96. raise
  97. # Output result
  98. if out:
  99. out.parent.mkdir(parents=True, exist_ok=True)
  100. out.write_text(content)
  101. console.print(f"[green]✅ Generated to {out}[/green]")
  102. else:
  103. console.print(f"\n{'='*60}\nGenerated Content:\n{'='*60}")
  104. console.print(content)
  105. def _validate_template(self, template, template_id: str) -> None:
  106. """Validate template and raise error if validation fails."""
  107. # Template is now self-validating, no need for registered variables
  108. warnings = template.validate()
  109. # If there are non-critical warnings, log them but don't fail
  110. if warnings:
  111. logger.warning(f"Template '{template_id}' has validation warnings: {warnings}")
  112. def _process_variables(self, template) -> Dict[str, Any]:
  113. """Process template variables with prompting."""
  114. # Use template's analyzed variables
  115. if not template.variables:
  116. return {}
  117. # Apply metadata to variables
  118. self._apply_metadata_to_variables(template.variables, template.variable_metadata)
  119. # Collect defaults from analyzed variables
  120. defaults = {}
  121. for var_name, var in template.variables.items():
  122. if var.default is not None:
  123. defaults[var_name] = var.default
  124. # Use rich output if enabled
  125. if not get_config().use_rich_output:
  126. # Simple fallback - just prompt for missing values
  127. values = defaults.copy()
  128. for var_name, var in template.variables.items():
  129. if var_name not in values:
  130. values[var_name] = input(f"Enter {var_name}: ")
  131. return values
  132. # Pass metadata to prompt handler
  133. prompt_handler = SimplifiedPromptHandler(template.variables)
  134. prompt_handler.category_metadata = self.metadata.get('categories', {})
  135. return prompt_handler()
  136. def _apply_metadata_to_variables(self, variables: Dict[str, Any], template_metadata: Dict[str, Any]):
  137. """Apply metadata from module and template to variables."""
  138. # First apply module metadata
  139. module_var_metadata = self.metadata.get('variables', {})
  140. for var_name, var in variables.items():
  141. if var_name in module_var_metadata:
  142. meta = module_var_metadata[var_name]
  143. if 'hint' in meta and not var.hint:
  144. var.hint = meta['hint']
  145. if 'description' in meta and not var.description:
  146. var.description = meta['description']
  147. if 'tip' in meta and not var.tip:
  148. var.tip = meta['tip']
  149. if 'validation' in meta and not var.validation:
  150. var.validation = meta['validation']
  151. if 'icon' in meta and not var.icon:
  152. var.icon = meta['icon']
  153. # Then apply template metadata (overrides module metadata)
  154. for var_name, var in variables.items():
  155. if var_name in template_metadata:
  156. meta = template_metadata[var_name]
  157. if 'hint' in meta:
  158. var.hint = meta['hint']
  159. if 'description' in meta:
  160. var.description = meta['description']
  161. if 'tip' in meta:
  162. var.tip = meta['tip']
  163. if 'validation' in meta:
  164. var.validation = meta['validation']
  165. if 'icon' in meta:
  166. var.icon = meta['icon']
  167. def register_cli(self, app: Typer):
  168. """Register module commands with the main app."""
  169. module_app = Typer()
  170. module_app.command()(self.list)
  171. module_app.command()(self.show)
  172. module_app.command()(self.generate)
  173. app.add_typer(module_app, name=self.name, help=self.description)