base_module.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. """Base module class for template management."""
  2. from __future__ import annotations
  3. import logging
  4. from abc import ABC
  5. from typing import Annotated
  6. from typer import Argument, Option, Typer
  7. from ..display import DisplayManager
  8. from ..library import LibraryManager
  9. from ..template import Template
  10. from .base_commands import (
  11. generate_template,
  12. list_templates,
  13. search_templates,
  14. show_template,
  15. validate_templates,
  16. )
  17. from .config_commands import (
  18. config_clear,
  19. config_get,
  20. config_list,
  21. config_remove,
  22. config_set,
  23. )
  24. logger = logging.getLogger(__name__)
  25. # Expected length of library entry tuple: (path, library_name, needs_qualification)
  26. LIBRARY_ENTRY_MIN_LENGTH = 2
  27. class Module(ABC):
  28. """Streamlined base module that auto-detects variables from templates.
  29. Subclasses must define:
  30. - name: str (class attribute)
  31. - description: str (class attribute)
  32. """
  33. # Class attributes that must be defined by subclasses
  34. name: str
  35. description: str
  36. # Schema version supported by this module (override in subclasses)
  37. schema_version: str = "1.0"
  38. def __init__(self) -> None:
  39. # Validate required class attributes
  40. if not hasattr(self.__class__, "name") or not hasattr(
  41. self.__class__, "description"
  42. ):
  43. raise TypeError(
  44. f"Module {self.__class__.__name__} must define 'name' and 'description' class attributes"
  45. )
  46. logger.info(f"Initializing module '{self.name}'")
  47. logger.debug(
  48. f"Module '{self.name}' configuration: description='{self.description}'"
  49. )
  50. self.libraries = LibraryManager()
  51. self.display = DisplayManager()
  52. def _load_all_templates(self, filter_fn=None) -> list:
  53. """Load all templates for this module with optional filtering."""
  54. templates = []
  55. entries = self.libraries.find(self.name, sort_results=True)
  56. for entry in entries:
  57. # Unpack entry - returns (path, library_name, needs_qualification)
  58. template_dir = entry[0]
  59. library_name = entry[1]
  60. needs_qualification = (
  61. entry[2] if len(entry) > LIBRARY_ENTRY_MIN_LENGTH else False
  62. )
  63. try:
  64. # Get library object to determine type
  65. library = next(
  66. (
  67. lib
  68. for lib in self.libraries.libraries
  69. if lib.name == library_name
  70. ),
  71. None,
  72. )
  73. library_type = library.library_type if library else "git"
  74. template = Template(
  75. template_dir, library_name=library_name, library_type=library_type
  76. )
  77. # Validate schema version compatibility
  78. template._validate_schema_version(self.schema_version, self.name)
  79. # If template ID needs qualification, set qualified ID
  80. if needs_qualification:
  81. template.set_qualified_id()
  82. # Apply filter if provided
  83. if filter_fn is None or filter_fn(template):
  84. templates.append(template)
  85. except Exception as exc:
  86. logger.error(f"Failed to load template from {template_dir}: {exc}")
  87. continue
  88. return templates
  89. def _load_template_by_id(self, id: str):
  90. """Load a template by its ID, supporting qualified IDs."""
  91. logger.debug(f"Loading template with ID '{id}' from module '{self.name}'")
  92. # find_by_id now handles both simple and qualified IDs
  93. result = self.libraries.find_by_id(self.name, id)
  94. if not result:
  95. raise FileNotFoundError(
  96. f"Template '{id}' not found in module '{self.name}'"
  97. )
  98. template_dir, library_name = result
  99. # Get library type
  100. library = next(
  101. (lib for lib in self.libraries.libraries if lib.name == library_name), None
  102. )
  103. library_type = library.library_type if library else "git"
  104. try:
  105. template = Template(
  106. template_dir, library_name=library_name, library_type=library_type
  107. )
  108. # Validate schema version compatibility
  109. template._validate_schema_version(self.schema_version, self.name)
  110. # If the original ID was qualified, preserve it
  111. if "." in id:
  112. template.id = id
  113. return template
  114. except Exception as exc:
  115. logger.error(f"Failed to load template '{id}': {exc}")
  116. raise FileNotFoundError(
  117. f"Template '{id}' could not be loaded: {exc}"
  118. ) from exc
  119. def list(
  120. self,
  121. raw: Annotated[
  122. bool, Option("--raw", help="Output raw list format instead of rich table")
  123. ] = False,
  124. ) -> list:
  125. """List all templates."""
  126. return list_templates(self, raw)
  127. def search(
  128. self,
  129. query: Annotated[str, Argument(help="Search string to filter templates by ID")],
  130. ) -> list:
  131. """Search for templates by ID containing the search string."""
  132. return search_templates(self, query)
  133. def show(
  134. self,
  135. id: str,
  136. var: Annotated[
  137. list[str] | None,
  138. Option(
  139. "--var",
  140. "-v",
  141. help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
  142. ),
  143. ] = None,
  144. var_file: Annotated[
  145. str | None,
  146. Option(
  147. "--var-file",
  148. "-f",
  149. help="Load variables from YAML file (overrides config defaults)",
  150. ),
  151. ] = None,
  152. ) -> None:
  153. """Show template details with optional variable overrides."""
  154. return show_template(self, id, var, var_file)
  155. def generate(
  156. self,
  157. id: Annotated[str, Argument(help="Template ID")],
  158. directory: Annotated[
  159. str | None, Argument(help="Output directory (defaults to template ID)")
  160. ] = None,
  161. interactive: Annotated[
  162. bool,
  163. Option(
  164. "--interactive/--no-interactive",
  165. "-i/-n",
  166. help="Enable interactive prompting for variables",
  167. ),
  168. ] = True,
  169. var: Annotated[
  170. list[str] | None,
  171. Option(
  172. "--var",
  173. "-v",
  174. help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
  175. ),
  176. ] = None,
  177. var_file: Annotated[
  178. str | None,
  179. Option(
  180. "--var-file",
  181. "-f",
  182. help="Load variables from YAML file (overrides config defaults, overridden by --var)",
  183. ),
  184. ] = None,
  185. dry_run: Annotated[
  186. bool,
  187. Option(
  188. "--dry-run", help="Preview template generation without writing files"
  189. ),
  190. ] = False,
  191. show_files: Annotated[
  192. bool,
  193. Option(
  194. "--show-files",
  195. help="Display generated file contents in plain text (use with --dry-run)",
  196. ),
  197. ] = False,
  198. quiet: Annotated[
  199. bool, Option("--quiet", "-q", help="Suppress all non-error output")
  200. ] = False,
  201. ) -> None:
  202. """Generate from template.
  203. Variable precedence chain (lowest to highest):
  204. 1. Module spec (defined in cli/modules/*.py)
  205. 2. Template spec (from template.yaml)
  206. 3. Config defaults (from ~/.config/boilerplates/config.yaml)
  207. 4. Variable file (from --var-file)
  208. 5. CLI overrides (--var flags)
  209. """
  210. return generate_template(
  211. self, id, directory, interactive, var, var_file, dry_run, show_files, quiet
  212. )
  213. def validate(
  214. self,
  215. template_id: str | None = None,
  216. path: str | None = None,
  217. verbose: Annotated[
  218. bool, Option("--verbose", "-v", help="Show detailed validation information")
  219. ] = False,
  220. semantic: Annotated[
  221. bool,
  222. Option(
  223. "--semantic/--no-semantic",
  224. help="Enable semantic validation (Docker Compose schema, etc.)",
  225. ),
  226. ] = True,
  227. ) -> None:
  228. """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
  229. return validate_templates(self, template_id, path, verbose, semantic)
  230. def config_get(
  231. self,
  232. var_name: str | None = None,
  233. ) -> None:
  234. """Get default value(s) for this module."""
  235. return config_get(self, var_name)
  236. def config_set(
  237. self,
  238. var_name: str,
  239. value: str | None = None,
  240. ) -> None:
  241. """Set a default value for a variable."""
  242. return config_set(self, var_name, value)
  243. def config_remove(
  244. self,
  245. var_name: Annotated[str, Argument(help="Variable name to remove")],
  246. ) -> None:
  247. """Remove a specific default variable value."""
  248. return config_remove(self, var_name)
  249. def config_clear(
  250. self,
  251. var_name: str | None = None,
  252. force: bool = False,
  253. ) -> None:
  254. """Clear default value(s) for this module."""
  255. return config_clear(self, var_name, force)
  256. def config_list(self) -> None:
  257. """Display the defaults for this specific module in YAML format."""
  258. return config_list(self)
  259. @classmethod
  260. def register_cli(cls, app: Typer) -> None:
  261. """Register module commands with the main app."""
  262. logger.debug(f"Registering CLI commands for module '{cls.name}'")
  263. module_instance = cls()
  264. module_app = Typer(help=cls.description)
  265. module_app.command("list")(module_instance.list)
  266. module_app.command("search")(module_instance.search)
  267. module_app.command("show")(module_instance.show)
  268. module_app.command("validate")(module_instance.validate)
  269. module_app.command(
  270. "generate",
  271. context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
  272. )(module_instance.generate)
  273. # Add defaults commands (simplified - only manage default values)
  274. defaults_app = Typer(help="Manage default values for template variables")
  275. defaults_app.command("get", help="Get default value(s)")(
  276. module_instance.config_get
  277. )
  278. defaults_app.command("set", help="Set a default value")(
  279. module_instance.config_set
  280. )
  281. defaults_app.command("rm", help="Remove a specific default value")(
  282. module_instance.config_remove
  283. )
  284. defaults_app.command("clear", help="Clear default value(s)")(
  285. module_instance.config_clear
  286. )
  287. defaults_app.command(
  288. "list", help="Display the config for this module in YAML format"
  289. )(module_instance.config_list)
  290. module_app.add_typer(defaults_app, name="defaults")
  291. app.add_typer(
  292. module_app,
  293. name=cls.name,
  294. help=cls.description,
  295. rich_help_panel="Template Commands",
  296. )
  297. logger.info(f"Module '{cls.name}' CLI commands registered")