module.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. from abc import ABC
  2. from pathlib import Path
  3. from typing import Optional, Dict, Any
  4. import logging
  5. import yaml
  6. from typer import Typer, Option, Argument
  7. from rich.console import Console
  8. # Using standard Python exceptions
  9. from .library import LibraryManager
  10. logger = logging.getLogger(__name__)
  11. console = Console()
  12. class Module(ABC):
  13. """Streamlined base module that auto-detects variables from templates."""
  14. # Required class attributes for subclasses
  15. name = None
  16. description = None
  17. files = None
  18. def __init__(self):
  19. if not all([self.name, self.description, self.files]):
  20. raise ValueError(
  21. f"Module {self.__class__.__name__} must define name, description, and files"
  22. )
  23. logger.info(f"Initializing module '{self.name}'")
  24. logger.debug(f"Module '{self.name}' configuration: files={self.files}, description='{self.description}'")
  25. self.libraries = LibraryManager()
  26. # Initialize variables if the subclass defines _init_variables method
  27. if hasattr(self, '_init_variables'):
  28. logger.debug(f"Module '{self.name}' has variable initialization method")
  29. self._init_variables()
  30. # Validate module variable registry consistency after initialization
  31. # NOTE: This ensures the module's variable hierarchy is properly structured (e.g., traefik.host requires traefik to exist).
  32. # The registry defines parent-child relationships where child variables like 'traefik.tls.certresolver' can only be used
  33. # when their parents ('traefik' and 'traefik.tls') are enabled. This prevents invalid module configurations.
  34. if hasattr(self, 'variables') and self.variables:
  35. var_count = len(self.variables.get_all_variables())
  36. logger.info(f"Module '{self.name}' registered {var_count} variables")
  37. registry_errors = self.variables.validate_parent_child_relationships()
  38. if registry_errors:
  39. error_msg = f"Module '{self.name}' has invalid variable registry:\n" + "\n".join(f" - {e}" for e in registry_errors)
  40. logger.error(error_msg)
  41. raise ValueError(error_msg)
  42. logger.debug(f"Module '{self.name}' variable registry validation completed successfully")
  43. self.metadata = self._build_metadata()
  44. logger.info(f"Module '{self.name}' initialization completed successfully")
  45. def _build_metadata(self) -> Dict[str, Any]:
  46. """Build metadata from class attributes."""
  47. metadata = {}
  48. # Add categories if defined
  49. if hasattr(self, 'categories'):
  50. metadata['categories'] = self.categories
  51. # Add variable metadata if defined
  52. if hasattr(self, 'variable_metadata'):
  53. metadata['variables'] = self.variable_metadata
  54. return metadata
  55. def list(self):
  56. """List all templates."""
  57. logger.debug(f"Listing templates for module '{self.name}'")
  58. templates = self.libraries.find(self.name, self.files, sorted=True)
  59. if templates:
  60. logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
  61. else:
  62. logger.info(f"No templates found for module '{self.name}'")
  63. # Display templates without enrichment (enrichment only needed for generation)
  64. for template in templates:
  65. console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
  66. return templates
  67. def show(self, id: str = Argument(..., help="Template ID")):
  68. """Show template details."""
  69. logger.debug(f"Showing template '{id}' from module '{self.name}'")
  70. # Get template directly from library without enrichment (not needed for display)
  71. template = self.libraries.find_by_id(self.name, self.files, id)
  72. if not template:
  73. logger.debug(f"Template '{id}' not found in module '{self.name}'")
  74. raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
  75. # Header
  76. version = f" v{template.version}" if template.version else ""
  77. console.print(f"[bold magenta]{template.name} ({template.id}{version})[/bold magenta]")
  78. console.print(f"[dim white]{template.description}[/dim white]\n")
  79. # Metadata (only print if exists)
  80. metadata = [
  81. ("Author", template.author),
  82. ("Date", template.date),
  83. ("Tags", ', '.join(template.tags) if template.tags else None)
  84. ]
  85. for label, value in metadata:
  86. if value:
  87. console.print(f"{label}: [cyan]{value}[/cyan]")
  88. # Variables (show raw template variables without module enrichment)
  89. if template.vars:
  90. console.print(f"Variables: [cyan]{', '.join(sorted(template.vars))}[/cyan]")
  91. # Content
  92. if template.content:
  93. print(f"\n{template.content}")
  94. def _enrich_template_with_variables(self, template):
  95. """Enrich template with module variable registry defaults (optimized).
  96. This method updates the template's vars with module defaults while preserving
  97. template-specific variables and frontmatter definitions.
  98. Args:
  99. template: Template instance to enrich
  100. """
  101. # Skip if already enriched or no variables
  102. if template._is_enriched or not hasattr(self, 'variables') or not self.variables:
  103. if template._is_enriched:
  104. logger.debug(f"Template '{template.id}' already enriched, skipping")
  105. else:
  106. logger.debug(f"Module '{self.name}' has no variables, skipping enrichment for '{template.id}'")
  107. return
  108. logger.debug(f"Enriching template '{template.id}' with {len(self.variables.get_all_variables())} module variables")
  109. # Get template variables first (this is cached)
  110. template_vars = template._parse_template_variables(
  111. template.content,
  112. getattr(template, 'frontmatter_variables', {})
  113. )
  114. # Only get module variables that are actually used in the template
  115. used_variables = template._get_used_variables()
  116. module_vars = {}
  117. module_defaults = {}
  118. for var_name in used_variables:
  119. var_obj = self.variables.get_variable(var_name)
  120. if var_obj:
  121. module_vars[var_name] = var_obj.default if var_obj.default is not None else None
  122. if var_obj.default is not None:
  123. module_defaults[var_name] = var_obj.default
  124. if module_defaults:
  125. logger.debug(f"Module provides {len(module_defaults)} defaults for used variables")
  126. logger.debug(f"Module default values: {module_defaults}")
  127. # Merge with template taking precedence
  128. final_vars = dict(module_vars)
  129. overrides = {}
  130. for var_name, var_value in template_vars.items():
  131. if var_name in final_vars and final_vars[var_name] != var_value and var_value is not None:
  132. logger.warning(
  133. f"Variable '{var_name}' defined in both module and template. Template takes precedence."
  134. )
  135. overrides[var_name] = var_value
  136. final_vars[var_name] = var_value
  137. if overrides:
  138. logger.debug(f"Template overrode {len(overrides)} module variables")
  139. # Set final variables and mark as enriched
  140. template.vars = final_vars
  141. template._is_enriched = True
  142. if final_vars:
  143. logger.info(f"Template '{template.id}' enriched with {len(final_vars)} variables from module '{self.name}'")
  144. else:
  145. logger.debug(f"Template '{template.id}' has no variables after enrichment")
  146. def _check_template_readiness(self, template):
  147. """Check if template is ready for generation (replaces complex validation).
  148. Args:
  149. template: Template instance to check
  150. Raises:
  151. ValueError: If template has critical issues preventing generation
  152. """
  153. logger.debug(f"Checking template readiness for '{template.id}'")
  154. errors = []
  155. # Check for basic template issues
  156. if not template.content.strip():
  157. errors.append("Template has no content")
  158. # Check for undefined variables (variables used but not available)
  159. undefined_vars = []
  160. for var_name, var_value in template.vars.items():
  161. if var_value is None:
  162. # Check if it's in module registry
  163. if hasattr(self, 'variables') and self.variables:
  164. var_obj = self.variables.get_variable(var_name)
  165. if not var_obj:
  166. # Not in module registry and no template default - problematic
  167. undefined_vars.append(var_name)
  168. if undefined_vars:
  169. errors.append(
  170. f"Template uses undefined variables: {', '.join(undefined_vars)}. "
  171. f"These variables are not registered in the module and have no template defaults."
  172. )
  173. # Check for syntax errors by attempting to create AST
  174. try:
  175. template._get_ast()
  176. except Exception as e:
  177. errors.append(f"Template has Jinja2 syntax errors: {str(e)}")
  178. if errors:
  179. logger.error(f"Template '{template.id}' failed readiness check with {len(errors)} errors")
  180. error_msg = f"Template '{template.id}' is not ready for generation:\n" + "\n".join(f" - {e}" for e in errors)
  181. raise ValueError(error_msg)
  182. logger.debug(f"Template '{template.id}' passed readiness check")
  183. def generate(
  184. self,
  185. id: str = Argument(..., help="Template ID"),
  186. out: Optional[Path] = Option(None, "--out", "-o")
  187. ):
  188. """Generate from template."""
  189. logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
  190. # Fetch template from library
  191. template = self.libraries.find_by_id(self.name, self.files, id)
  192. if not template:
  193. logger.error(f"Template '{id}' not found for generation in module '{self.name}'")
  194. raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
  195. # Enrich template with module variables if available
  196. self._enrich_template_with_variables(template)
  197. # Check for critical template issues after enrichment
  198. self._check_template_readiness(template)
  199. logger.info(f"Template '{id}' generation completed successfully for module '{self.name}'")
  200. print("TEST SUCCESSFUL")
  201. def register_cli(self, app: Typer):
  202. """Register module commands with the main app."""
  203. logger.debug(f"Registering CLI commands for module '{self.name}'")
  204. module_app = Typer()
  205. module_app.command()(self.list)
  206. module_app.command()(self.show)
  207. module_app.command()(self.generate)
  208. app.add_typer(module_app, name=self.name, help=self.description)
  209. logger.info(f"Module '{self.name}' CLI commands registered")