library.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. from __future__ import annotations
  2. from pathlib import Path
  3. import logging
  4. from typing import Optional
  5. import yaml
  6. from .exceptions import LibraryError, TemplateNotFoundError, YAMLParseError, DuplicateTemplateError
  7. logger = logging.getLogger(__name__)
  8. class Library:
  9. """Represents a single library with a specific path."""
  10. def __init__(self, name: str, path: Path, priority: int = 0, library_type: str = "git") -> None:
  11. """Initialize a library instance.
  12. Args:
  13. name: Display name for the library
  14. path: Path to the library directory
  15. priority: Priority for library lookup (higher = checked first)
  16. library_type: Type of library ("git" or "static")
  17. """
  18. if library_type not in ("git", "static"):
  19. raise ValueError(f"Invalid library type: {library_type}. Must be 'git' or 'static'.")
  20. self.name = name
  21. self.path = path
  22. self.priority = priority # Higher priority = checked first
  23. self.library_type = library_type
  24. def _is_template_draft(self, template_path: Path) -> bool:
  25. """Check if a template is marked as draft."""
  26. # Find the template file
  27. for filename in ("template.yaml", "template.yml"):
  28. template_file = template_path / filename
  29. if template_file.exists():
  30. break
  31. else:
  32. return False
  33. try:
  34. with open(template_file, "r", encoding="utf-8") as f:
  35. docs = [doc for doc in yaml.safe_load_all(f) if doc]
  36. return docs[0].get("metadata", {}).get("draft", False) if docs else False
  37. except (yaml.YAMLError, IOError, OSError) as e:
  38. logger.warning(f"Error checking draft status for {template_path}: {e}")
  39. return False
  40. def find_by_id(self, module_name: str, template_id: str) -> tuple[Path, str]:
  41. """Find a template by its ID in this library.
  42. Args:
  43. module_name: The module name (e.g., 'compose', 'terraform')
  44. template_id: The template ID to find
  45. Returns:
  46. Path to the template directory if found
  47. Raises:
  48. FileNotFoundError: If the template ID is not found in this library or is marked as draft
  49. """
  50. logger.debug(f"Looking for template '{template_id}' in module '{module_name}' in library '{self.name}'")
  51. # Build the path to the specific template directory
  52. template_path = self.path / module_name / template_id
  53. # Check if template directory exists with a template file
  54. has_template = template_path.is_dir() and any(
  55. (template_path / f).exists() for f in ("template.yaml", "template.yml")
  56. )
  57. if not has_template or self._is_template_draft(template_path):
  58. raise TemplateNotFoundError(template_id, module_name)
  59. logger.debug(f"Found template '{template_id}' at: {template_path}")
  60. return template_path, self.name
  61. def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str]]:
  62. """Find templates in this library for a specific module.
  63. Excludes templates marked as draft.
  64. Args:
  65. module_name: The module name (e.g., 'compose', 'terraform')
  66. sort_results: Whether to return results sorted alphabetically
  67. Returns:
  68. List of Path objects representing template directories (excluding drafts)
  69. Raises:
  70. FileNotFoundError: If the module directory is not found in this library
  71. """
  72. logger.debug(f"Looking for templates in module '{module_name}' in library '{self.name}'")
  73. # Build the path to the module directory
  74. module_path = self.path / module_name
  75. # Check if the module directory exists
  76. if not module_path.is_dir():
  77. raise LibraryError(f"Module '{module_name}' not found in library '{self.name}'")
  78. # Track seen IDs to detect duplicates within this library
  79. seen_ids = {}
  80. template_dirs = []
  81. try:
  82. for item in module_path.iterdir():
  83. has_template = item.is_dir() and any((item / f).exists() for f in ("template.yaml", "template.yml"))
  84. if has_template and not self._is_template_draft(item):
  85. template_id = item.name
  86. # Check for duplicate within same library
  87. if template_id in seen_ids:
  88. raise DuplicateTemplateError(template_id, self.name)
  89. seen_ids[template_id] = True
  90. template_dirs.append((item, self.name))
  91. elif has_template:
  92. logger.debug(f"Skipping draft template: {item.name}")
  93. except PermissionError as e:
  94. raise LibraryError(f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}")
  95. # Sort if requested
  96. if sort_results:
  97. template_dirs.sort(key=lambda x: x[0].name.lower())
  98. logger.debug(f"Found {len(template_dirs)} templates in module '{module_name}'")
  99. return template_dirs
  100. class LibraryManager:
  101. """Manages multiple libraries and provides methods to find templates."""
  102. def __init__(self) -> None:
  103. """Initialize LibraryManager with git-based libraries from config."""
  104. from .config import ConfigManager
  105. self.config = ConfigManager()
  106. self.libraries = self._load_libraries_from_config()
  107. def _load_libraries_from_config(self) -> list[Library]:
  108. """Load libraries from configuration.
  109. Returns:
  110. List of Library instances
  111. """
  112. libraries = []
  113. libraries_path = self.config.get_libraries_path()
  114. # Get library configurations from config
  115. library_configs = self.config.get_libraries()
  116. for i, lib_config in enumerate(library_configs):
  117. # Skip disabled libraries
  118. if not lib_config.get("enabled", True):
  119. logger.debug(f"Skipping disabled library: {lib_config.get('name')}")
  120. continue
  121. name = lib_config.get("name")
  122. lib_type = lib_config.get("type", "git") # Default to "git" for backward compat
  123. # Handle library type-specific path resolution
  124. if lib_type == "git":
  125. # Existing git logic
  126. directory = lib_config.get("directory", ".")
  127. # Build path to library: ~/.config/boilerplates/libraries/{name}/{directory}/
  128. # For sparse-checkout, files remain in the specified directory
  129. library_base = libraries_path / name
  130. if directory and directory != ".":
  131. library_path = library_base / directory
  132. else:
  133. library_path = library_base
  134. elif lib_type == "static":
  135. # New static logic - use path directly
  136. path_str = lib_config.get("path")
  137. if not path_str:
  138. logger.warning(f"Static library '{name}' has no path configured")
  139. continue
  140. # Expand ~ and resolve relative paths
  141. library_path = Path(path_str).expanduser()
  142. if not library_path.is_absolute():
  143. # Resolve relative to config directory
  144. library_path = (self.config.config_path.parent / library_path).resolve()
  145. else:
  146. logger.warning(f"Unknown library type '{lib_type}' for library '{name}'")
  147. continue
  148. # Check if library path exists
  149. if not library_path.exists():
  150. if lib_type == "git":
  151. logger.warning(
  152. f"Library '{name}' not found at {library_path}. "
  153. f"Run 'repo update' to sync libraries."
  154. )
  155. else:
  156. logger.warning(f"Static library '{name}' not found at {library_path}")
  157. continue
  158. # Create Library instance with type and priority based on order (first = highest priority)
  159. priority = len(library_configs) - i
  160. libraries.append(
  161. Library(name=name, path=library_path, priority=priority, library_type=lib_type)
  162. )
  163. logger.debug(f"Loaded {lib_type} library '{name}' from {library_path} with priority {priority}")
  164. if not libraries:
  165. logger.warning("No libraries loaded. Run 'repo update' to sync libraries.")
  166. return libraries
  167. def find_by_id(self, module_name: str, template_id: str) -> Optional[tuple[Path, str]]:
  168. """Find a template by its ID across all libraries.
  169. Supports both simple IDs and qualified IDs (template.library format).
  170. Args:
  171. module_name: The module name (e.g., 'compose', 'terraform')
  172. template_id: The template ID to find (simple or qualified)
  173. Returns:
  174. Tuple of (template_path, library_name) if found, None otherwise
  175. """
  176. logger.debug(f"Searching for template '{template_id}' in module '{module_name}' across all libraries")
  177. # Check if this is a qualified ID (contains '.')
  178. if '.' in template_id:
  179. parts = template_id.rsplit('.', 1)
  180. if len(parts) == 2:
  181. base_id, requested_lib = parts
  182. logger.debug(f"Parsing qualified ID: base='{base_id}', library='{requested_lib}'")
  183. # Try to find in the specific library
  184. for library in self.libraries:
  185. if library.name == requested_lib:
  186. try:
  187. template_path, lib_name = library.find_by_id(module_name, base_id)
  188. logger.debug(f"Found template '{base_id}' in library '{requested_lib}'")
  189. return template_path, lib_name
  190. except TemplateNotFoundError:
  191. logger.debug(f"Template '{base_id}' not found in library '{requested_lib}'")
  192. return None
  193. logger.debug(f"Library '{requested_lib}' not found")
  194. return None
  195. # Simple ID - search by priority
  196. for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
  197. try:
  198. template_path, lib_name = library.find_by_id(module_name, template_id)
  199. logger.debug(f"Found template '{template_id}' in library '{library.name}'")
  200. return template_path, lib_name
  201. except TemplateNotFoundError:
  202. # Continue searching in next library
  203. continue
  204. logger.debug(f"Template '{template_id}' not found in any library")
  205. return None
  206. def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str, bool]]:
  207. """Find templates across all libraries for a specific module.
  208. Handles duplicates by qualifying IDs with library names when needed.
  209. Args:
  210. module_name: The module name (e.g., 'compose', 'terraform')
  211. sort_results: Whether to return results sorted alphabetically
  212. Returns:
  213. List of tuples (template_path, library_name, needs_qualification)
  214. where needs_qualification is True if the template ID appears in multiple libraries
  215. """
  216. logger.debug(f"Searching for templates in module '{module_name}' across all libraries")
  217. all_templates = []
  218. # Collect templates from all libraries
  219. for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
  220. try:
  221. templates = library.find(module_name, sort_results=False)
  222. all_templates.extend(templates)
  223. logger.debug(f"Found {len(templates)} templates in library '{library.name}'")
  224. except (LibraryError, DuplicateTemplateError) as e:
  225. # DuplicateTemplateError from library.find() should propagate up
  226. if isinstance(e, DuplicateTemplateError):
  227. raise
  228. logger.debug(f"Module '{module_name}' not found in library '{library.name}'")
  229. continue
  230. # Track template IDs and their libraries to detect cross-library duplicates
  231. id_to_occurrences = {}
  232. for template_path, library_name in all_templates:
  233. template_id = template_path.name
  234. if template_id not in id_to_occurrences:
  235. id_to_occurrences[template_id] = []
  236. id_to_occurrences[template_id].append((template_path, library_name))
  237. # Build result with qualification markers for duplicates
  238. result = []
  239. for template_id, occurrences in id_to_occurrences.items():
  240. if len(occurrences) > 1:
  241. # Duplicate across libraries - mark for qualified IDs
  242. lib_names = ', '.join(lib for _, lib in occurrences)
  243. logger.info(
  244. f"Template '{template_id}' found in multiple libraries: {lib_names}. "
  245. f"Using qualified IDs."
  246. )
  247. for template_path, library_name in occurrences:
  248. # Mark that this ID needs qualification
  249. result.append((template_path, library_name, True))
  250. else:
  251. # Unique template - no qualification needed
  252. template_path, library_name = occurrences[0]
  253. result.append((template_path, library_name, False))
  254. # Sort if requested
  255. if sort_results:
  256. result.sort(key=lambda x: x[0].name.lower())
  257. logger.debug(f"Found {len(result)} templates total")
  258. return result