command.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. """
  2. Base classes and utilities for CLI modules and commands.
  3. Provides common functionality and patterns for all modules.
  4. """
  5. import logging
  6. from abc import ABC, abstractmethod
  7. from pathlib import Path
  8. from typing import Optional, Set, Dict, Any, List, Tuple
  9. from rich.console import Console
  10. import typer
  11. from .config import ConfigManager
  12. from .helpers import find_boilerplates
  13. from . import template, values, render
  14. class BaseModule(ABC):
  15. """Abstract base class for all CLI modules with shared commands."""
  16. def __init__(self, name: str, icon: str = "", description: str = ""):
  17. self.name = name
  18. self.icon = icon
  19. self.description = description
  20. self.console = Console()
  21. self.logger = logging.getLogger(f"boilerplates.module.{name}")
  22. @property
  23. def template_paths(self) -> List[str]:
  24. """Return list of valid template file paths/patterns for this module.
  25. Override this in modules that support template generation."""
  26. return []
  27. @property
  28. def library_path(self) -> Optional[Path]:
  29. """Return the path to the template library for this module.
  30. Override this in modules that support template generation."""
  31. return None
  32. @property
  33. def variable_handler_class(self) -> Any:
  34. """Return the variable handler class for this module."""
  35. return None
  36. def get_valid_variables(self) -> Set[str]:
  37. """Get the set of valid variable names for this module."""
  38. if self.variable_handler_class:
  39. handler = self.variable_handler_class()
  40. return set(handler._declared.keys())
  41. return set()
  42. def process_template_content(self, content: str) -> str:
  43. """Process template content before rendering. Override if needed."""
  44. return content
  45. def get_template_syntax(self) -> str:
  46. """Return the syntax highlighting to use for this template type."""
  47. return "yaml"
  48. def get_app(self) -> typer.Typer:
  49. """
  50. Create and return the Typer app with shared commands.
  51. Subclasses can override this to add module-specific commands.
  52. """
  53. app = typer.Typer(
  54. name=self.name,
  55. help=f"{self.icon} {self.description}",
  56. rich_markup_mode="rich"
  57. )
  58. # Add shared config commands
  59. self._add_config_commands(app)
  60. # Add module-specific commands
  61. self._add_module_commands(app)
  62. return app
  63. def _add_config_commands(self, app: typer.Typer) -> None:
  64. """
  65. Add shared configuration commands to the app.
  66. These commands are available for all modules.
  67. """
  68. config_app = typer.Typer(name="config", help="Manage module configuration")
  69. app.add_typer(config_app, name="config")
  70. @config_app.command("set", help="Set a configuration value")
  71. def set_config(
  72. key: str = typer.Argument(..., help="Configuration key"),
  73. value: str = typer.Argument(..., help="Configuration value")
  74. ):
  75. """Set a configuration value for this module."""
  76. # Validate that the key is a valid variable for this module
  77. valid_vars = self.get_valid_variables()
  78. if valid_vars and key not in valid_vars:
  79. self.console.print(f"[red]✗[/red] Invalid config key '{key}'. Valid keys are: {', '.join(sorted(valid_vars))}")
  80. raise typer.Exit(code=1)
  81. config_manager = ConfigManager(self.name)
  82. try:
  83. # Try to parse as JSON for complex values
  84. import json
  85. try:
  86. parsed_value = json.loads(value)
  87. except json.JSONDecodeError:
  88. parsed_value = value
  89. config_manager.set(key, parsed_value)
  90. self.console.print(f"[green]✓[/green] Set {self.name} config '{key}' = {parsed_value}")
  91. except Exception as e:
  92. self.console.print(f"[red]✗[/red] Failed to set config: {e}")
  93. @config_app.command("get", help="Get a configuration value")
  94. def get_config(
  95. key: str = typer.Argument(..., help="Configuration key"),
  96. default: Optional[str] = typer.Option(None, "--default", "-d", help="Default value if key not found")
  97. ):
  98. """Get a configuration value for this module."""
  99. config_manager = ConfigManager(self.name)
  100. value = config_manager.get(key, default)
  101. if value is None:
  102. self.console.print(f"[yellow]⚠[/yellow] Config key '{key}' not found")
  103. return
  104. import json
  105. if isinstance(value, (dict, list)):
  106. self.console.print(json.dumps(value, indent=2))
  107. else:
  108. self.console.print(f"{key}: {value}")
  109. @config_app.command("list", help="List all configuration values")
  110. def list_config():
  111. """List all configuration values for this module."""
  112. config_manager = ConfigManager(self.name)
  113. config = config_manager.list_all()
  114. if not config:
  115. self.console.print(f"[yellow]No configuration found for {self.name}[/yellow]")
  116. return
  117. from rich.table import Table
  118. table = Table(title=f"⚙️ {self.name.title()} Configuration", title_style="bold blue")
  119. table.add_column("Key", style="cyan", no_wrap=True)
  120. table.add_column("Value", style="green")
  121. import json
  122. for key, value in config.items():
  123. if isinstance(value, (dict, list)):
  124. value_str = json.dumps(value, indent=2)
  125. else:
  126. value_str = str(value)
  127. table.add_row(key, value_str)
  128. self.console.print(table)
  129. @config_app.command("delete", help="Delete a configuration value")
  130. def delete_config(key: str = typer.Argument(..., help="Configuration key")):
  131. """Delete a configuration value for this module."""
  132. config_manager = ConfigManager(self.name)
  133. if config_manager.delete(key):
  134. self.console.print(f"[green]✓[/green] Deleted config key '{key}'")
  135. else:
  136. self.console.print(f"[yellow]⚠[/yellow] Config key '{key}' not found")
  137. @config_app.command("variables", help="List valid configuration variables for this module")
  138. def list_variables():
  139. """List all valid configuration variables for this module."""
  140. valid_vars = self.get_valid_variables()
  141. if not valid_vars:
  142. self.console.print(f"[yellow]No variables defined for {self.name} module yet.[/yellow]")
  143. return
  144. from rich.table import Table
  145. table = Table(title=f"🔧 Valid {self.name.title()} Variables", title_style="bold blue")
  146. table.add_column("Variable Name", style="cyan", no_wrap=True)
  147. table.add_column("Set", style="magenta")
  148. table.add_column("Type", style="green")
  149. table.add_column("Description", style="dim")
  150. # Get detailed variable information
  151. if hasattr(self, '_get_variable_details'):
  152. var_details = self._get_variable_details()
  153. for var_name in sorted(valid_vars):
  154. if var_name in var_details:
  155. detail = var_details[var_name]
  156. table.add_row(
  157. var_name,
  158. detail.get('set', 'unknown'),
  159. detail.get('type', 'str'),
  160. detail.get('display_name', '')
  161. )
  162. else:
  163. table.add_row(var_name, 'unknown', 'str', '')
  164. else:
  165. for var_name in sorted(valid_vars):
  166. table.add_row(var_name, 'unknown', 'str', '')
  167. self.console.print(table)
  168. def _add_module_commands(self, app: typer.Typer) -> None:
  169. """Add module-specific commands to the app."""
  170. # Only add generate command if module supports templates
  171. if self.library_path is not None and self.template_paths:
  172. self._add_generate_command(app)
  173. self._add_custom_commands(app)
  174. def _add_custom_commands(self, app: typer.Typer) -> None:
  175. """Override this method in subclasses to add module-specific commands."""
  176. pass
  177. def _add_generate_command(self, app: typer.Typer) -> None:
  178. """Add the generate command to the app."""
  179. @app.command("generate", help="Generate from a template and write to --out")
  180. def generate(
  181. name: str,
  182. out: Optional[Path] = typer.Option(None, "--out", "-o",
  183. help="Output path to write rendered template (prints to stdout when omitted)"),
  184. values_file: Optional[Path] = typer.Option(None, "--values-file", "-f",
  185. help="Load values from YAML/JSON file"),
  186. values: Optional[List[str]] = typer.Option(None, "--values",
  187. help="Set values (format: key=value)")
  188. ):
  189. """Generate output from a template with optional value overrides."""
  190. # Find and validate template
  191. bps = find_boilerplates(self.library_path, self.template_paths)
  192. bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
  193. if not bp:
  194. self.console.print(f"[red]Template '{name}' not found.[/red]")
  195. raise typer.Exit(code=1)
  196. # Get variable handler if module provides one
  197. var_handler = None
  198. if self.variable_handler_class:
  199. var_handler = self.variable_handler_class()
  200. # Clean and process template content
  201. content = self.process_template_content(bp.content)
  202. cleaned_content = template.clean_template_content(content)
  203. # Find variables if handler exists
  204. used_vars = set()
  205. if var_handler:
  206. _, used_vars = var_handler.determine_variable_sets(cleaned_content)
  207. if not used_vars:
  208. rendered = content
  209. else:
  210. # Validate template syntax
  211. is_valid, error = template.validate_template(cleaned_content, bp.file_path)
  212. if not is_valid:
  213. self.console.print(f"[red]{error}[/red]")
  214. raise typer.Exit(code=2)
  215. # Extract defaults and metadata if handler exists
  216. template_defaults = {}
  217. if var_handler:
  218. template_defaults = var_handler.extract_template_defaults(cleaned_content)
  219. try:
  220. meta_overrides = var_handler.extract_variable_meta_overrides(content)
  221. for var_name, overrides in meta_overrides.items():
  222. if var_name in var_handler._declared and isinstance(overrides, dict):
  223. existing = var_handler._declared[var_name][1]
  224. existing.update(overrides)
  225. except Exception:
  226. pass
  227. # Get subscript keys and load values from all sources
  228. used_subscripts = set()
  229. if var_handler:
  230. used_subscripts = var_handler.find_used_subscript_keys(content)
  231. # Load and merge values from all sources
  232. try:
  233. merged_values = values.load_and_merge_values(
  234. values_file=values_file,
  235. cli_values=values,
  236. config_values=ConfigManager(self.name).list_all(),
  237. defaults=template_defaults
  238. )
  239. except Exception as e:
  240. self.console.print(f"[red]{str(e)}[/red]")
  241. raise typer.Exit(code=1)
  242. # Collect final values and render template
  243. values_dict = {}
  244. if var_handler:
  245. values_dict = var_handler.collect_values(
  246. used_vars,
  247. merged_values,
  248. used_subscripts
  249. )
  250. else:
  251. values_dict = merged_values
  252. success, rendered, error = template.render_template(
  253. cleaned_content,
  254. values_dict
  255. )
  256. if not success:
  257. self.console.print(f"[red]{error}[/red]")
  258. raise typer.Exit(code=2)
  259. # Output the rendered content
  260. output_handler = render.RenderOutput(self.console)
  261. output_handler.output_rendered_content(
  262. rendered,
  263. out,
  264. self.get_template_syntax(),
  265. bp.name
  266. )