library.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. from pathlib import Path
  2. import subprocess
  3. import logging
  4. from .config import get_config, LibraryConfig
  5. # Using standard Python exceptions
  6. logger = logging.getLogger(__name__)
  7. class Library:
  8. """Represents a single library with a specific path."""
  9. def __init__(self, name: str, path: Path, priority: int = 0):
  10. self.name = name
  11. self.path = path
  12. self.priority = priority # Higher priority = checked first
  13. def find_by_id(self, module_name, files, template_id):
  14. """
  15. Find a template by its ID in this library.
  16. Args:
  17. module_name: The module name (e.g., 'terraform', 'compose') to search within.
  18. This narrows the search to the specific technology directory in the library.
  19. files: List of file patterns to search for (e.g., ['*.tf', '*.yaml']).
  20. This filters templates to only those with matching file extensions,
  21. ensuring we only process relevant template files for the module.
  22. template_id: The unique identifier of the template to find.
  23. This is typically derived from the template's directory name or filename.
  24. Returns:
  25. Template object if found, None otherwise.
  26. """
  27. from .template import Template # Import here to avoid circular import
  28. logger.debug(f"Searching for template '{template_id}' in library '{self.name}' at {self.path} (module: {module_name})")
  29. module_path = self.path / module_name
  30. if not module_path.exists():
  31. logger.debug(f"Module path '{module_path}' does not exist in library '{self.name}'")
  32. return None
  33. # Try to find the template directory directly by ID
  34. template_dir = module_path / template_id
  35. if template_dir.exists() and template_dir.is_dir():
  36. # Look for template files in this specific directory
  37. for filename in files:
  38. for file_path in template_dir.glob(filename):
  39. if file_path.is_file():
  40. template = Template.from_file(file_path)
  41. # Set module context if not already specified in frontmatter
  42. if not template.module:
  43. template.module = module_name
  44. # Verify this is actually the template we want
  45. if template.id == template_id:
  46. logger.info(f"Found template '{template_id}' in library '{self.name}' (direct lookup)")
  47. return template
  48. # Fallback to the original method if direct lookup fails
  49. # This handles cases where template ID doesn't match directory structure
  50. logger.debug(f"Direct lookup failed for '{template_id}', falling back to full scan in library '{self.name}'")
  51. for template in self.find(module_name, files, sorted=False):
  52. if template.id == template_id:
  53. logger.info(f"Found template '{template_id}' in library '{self.name}' (full scan)")
  54. return template
  55. logger.debug(f"Template '{template_id}' not found in library '{self.name}'")
  56. return None
  57. def find(self, module_name, files, sorted=False):
  58. """Find templates in this library for a specific module."""
  59. from .template import Template # Import here to avoid circular import
  60. logger.debug(f"Scanning for templates in library '{self.name}' (module: {module_name}, files: {files})")
  61. templates = []
  62. module_path = self.path / module_name
  63. if not module_path.exists():
  64. logger.debug(f"Module path '{module_path}' does not exist in library '{self.name}'")
  65. return templates
  66. # Find all files matching the specified filenames
  67. for filename in files:
  68. for file_path in module_path.rglob(filename):
  69. if file_path.is_file():
  70. # Create Template object using the new class method
  71. template = Template.from_file(file_path)
  72. # Set module context if not already specified in frontmatter
  73. if not template.module:
  74. template.module = module_name
  75. templates.append(template)
  76. if sorted:
  77. templates.sort(key=lambda t: t.id)
  78. if templates:
  79. logger.info(f"Found {len(templates)} templates in library '{self.name}' for module '{module_name}'")
  80. else:
  81. logger.debug(f"No templates found in library '{self.name}' for module '{module_name}'")
  82. return templates
  83. class RemoteLibrary(Library):
  84. """Support for Git-based remote template libraries."""
  85. def __init__(self, name: str, repo_url: str, branch: str = "main", priority: int = 0):
  86. """Initialize a remote library.
  87. Args:
  88. name: Name of the library
  89. repo_url: Git repository URL
  90. branch: Branch to use (default: main)
  91. priority: Library priority (higher = checked first)
  92. """
  93. self.repo_url = repo_url
  94. self.branch = branch
  95. # Set up local cache path
  96. config = get_config()
  97. local_cache = config.cache_dir / name
  98. # Initialize parent with cache path
  99. super().__init__(name, local_cache, priority)
  100. # Update the cache on initialization if configured
  101. if config.auto_update_remotes:
  102. try:
  103. self.update()
  104. except Exception as e:
  105. logger.warning(f"Failed to auto-update remote library '{name}': {e}")
  106. def update(self) -> bool:
  107. """Pull latest changes from remote repository.
  108. Returns:
  109. True if update was successful, False otherwise
  110. """
  111. try:
  112. if not self.path.exists():
  113. # Clone repository
  114. logger.info(f"Cloning remote library '{self.name}' from {self.repo_url}")
  115. self.path.parent.mkdir(parents=True, exist_ok=True)
  116. result = subprocess.run(
  117. ["git", "clone", "-b", self.branch, self.repo_url, str(self.path)],
  118. capture_output=True,
  119. text=True,
  120. check=True
  121. )
  122. if result.returncode != 0:
  123. raise ConnectionError(f"Git clone failed for '{self.name}': {result.stderr}")
  124. logger.info(f"Successfully cloned library '{self.name}'")
  125. return True
  126. else:
  127. # Pull updates
  128. logger.info(f"Updating remote library '{self.name}'")
  129. # First, fetch to see if there are updates
  130. result = subprocess.run(
  131. ["git", "fetch", "origin", self.branch],
  132. cwd=self.path,
  133. capture_output=True,
  134. text=True
  135. )
  136. if result.returncode != 0:
  137. logger.warning(f"Failed to fetch updates for '{self.name}': {result.stderr}")
  138. return False
  139. # Check if we're behind
  140. result = subprocess.run(
  141. ["git", "rev-list", "--count", f"HEAD..origin/{self.branch}"],
  142. cwd=self.path,
  143. capture_output=True,
  144. text=True
  145. )
  146. behind_count = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
  147. if behind_count > 0:
  148. # Pull the updates
  149. result = subprocess.run(
  150. ["git", "pull", "origin", self.branch],
  151. cwd=self.path,
  152. capture_output=True,
  153. text=True,
  154. check=True
  155. )
  156. if result.returncode != 0:
  157. raise ConnectionError(f"Git pull failed for '{self.name}': {result.stderr}")
  158. logger.info(f"Successfully updated library '{self.name}' ({behind_count} new commits)")
  159. return True
  160. else:
  161. logger.debug(f"Library '{self.name}' is already up to date")
  162. return True
  163. except subprocess.CalledProcessError as e:
  164. raise RuntimeError(
  165. f"Git command failed for '{self.name}': {e.stderr if hasattr(e, 'stderr') else str(e)}"
  166. )
  167. except Exception as e:
  168. raise RuntimeError(f"Library update failed for '{self.name}': {str(e)}")
  169. def get_info(self) -> dict:
  170. """Get information about the remote library.
  171. Returns:
  172. Dictionary with library information
  173. """
  174. info = {
  175. 'name': self.name,
  176. 'type': 'remote',
  177. 'repo': self.repo_url,
  178. 'branch': self.branch,
  179. 'priority': self.priority,
  180. 'cached': self.path.exists(),
  181. 'cache_path': str(self.path)
  182. }
  183. if self.path.exists():
  184. try:
  185. # Get current commit hash
  186. result = subprocess.run(
  187. ["git", "rev-parse", "HEAD"],
  188. cwd=self.path,
  189. capture_output=True,
  190. text=True
  191. )
  192. if result.returncode == 0:
  193. info['current_commit'] = result.stdout.strip()[:8]
  194. # Get last update time
  195. result = subprocess.run(
  196. ["git", "log", "-1", "--format=%ci"],
  197. cwd=self.path,
  198. capture_output=True,
  199. text=True
  200. )
  201. if result.returncode == 0:
  202. info['last_updated'] = result.stdout.strip()
  203. except Exception as e:
  204. logger.debug(f"Failed to get git info for '{self.name}': {e}")
  205. return info
  206. class LibraryManager:
  207. """Manager for multiple libraries with priority-based ordering."""
  208. def __init__(self):
  209. self.libraries = []
  210. self._initialize_libraries()
  211. def _initialize_libraries(self):
  212. """Initialize libraries from configuration."""
  213. config = get_config()
  214. logger.info(f"Initializing library manager with {len(config.libraries)} configured libraries")
  215. # First, add configured libraries
  216. for lib_config in config.libraries:
  217. try:
  218. library = self._create_library_from_config(lib_config)
  219. if library:
  220. self.libraries.append(library)
  221. logger.info(f"Loaded library '{lib_config.name}' (type: {lib_config.type}, priority: {lib_config.priority})")
  222. except Exception as e:
  223. logger.warning(f"Failed to load library '{lib_config.name}': {e}")
  224. # Then add the default built-in library if not already configured
  225. if not any(lib.name == "default" for lib in self.libraries):
  226. script_dir = Path(__file__).parent.parent.parent # Go up from cli/core/ to project root
  227. default_library = Library("default", script_dir / "library", priority=-1) # Lower priority
  228. self.libraries.append(default_library)
  229. logger.info(f"Added default built-in library at '{script_dir / 'library'}' (priority: -1)")
  230. # Sort libraries by priority (highest first)
  231. self._sort_by_priority()
  232. logger.info(f"Successfully initialized {len(self.libraries)} libraries")
  233. if self.libraries:
  234. logger.debug(f"Libraries in priority order: {[(lib.name, lib.priority) for lib in self.libraries]}")
  235. def _create_library_from_config(self, lib_config):
  236. """Create a Library instance from configuration.
  237. Args:
  238. lib_config: LibraryConfig instance
  239. Returns:
  240. Library instance or None if creation fails
  241. """
  242. if lib_config.type == "local":
  243. if lib_config.path:
  244. path = Path(lib_config.path).expanduser()
  245. if path.exists():
  246. return Library(lib_config.name, path, lib_config.priority)
  247. else:
  248. logger.warning(f"Local library path does not exist: {path}")
  249. return None
  250. elif lib_config.type == "git":
  251. if lib_config.repo:
  252. return RemoteLibrary(
  253. lib_config.name,
  254. lib_config.repo,
  255. lib_config.branch,
  256. lib_config.priority
  257. )
  258. else:
  259. logger.warning(f"Git library '{lib_config.name}' missing repo URL")
  260. return None
  261. else:
  262. logger.warning(f"Unknown library type: {lib_config.type}")
  263. return None
  264. def _sort_by_priority(self):
  265. """Sort libraries by priority (highest first)."""
  266. self.libraries.sort(key=lambda lib: lib.priority, reverse=True)
  267. def add_library(self, library: Library):
  268. """Add a library to the collection.
  269. Args:
  270. library: Library instance to add
  271. """
  272. # Check for duplicate names
  273. if any(lib.name == library.name for lib in self.libraries):
  274. logger.warning(f"Library '{library.name}' already exists, replacing")
  275. self.libraries = [lib for lib in self.libraries if lib.name != library.name]
  276. self.libraries.append(library)
  277. self._sort_by_priority()
  278. def find(self, module_name, files, sorted=False):
  279. """Find templates across all libraries for a specific module."""
  280. logger.debug(f"Searching across {len(self.libraries)} libraries for module '{module_name}'")
  281. all_templates = []
  282. library_counts = {}
  283. for library in self.libraries:
  284. templates = library.find(module_name, files, sorted=sorted)
  285. if templates:
  286. library_counts[library.name] = len(templates)
  287. all_templates.extend(templates)
  288. if sorted:
  289. all_templates.sort(key=lambda t: t.id)
  290. if all_templates:
  291. logger.info(f"Found {len(all_templates)} total templates for module '{module_name}'")
  292. if library_counts:
  293. logger.debug(f"Template distribution: {library_counts}")
  294. else:
  295. logger.debug(f"No templates found for module '{module_name}' across any library")
  296. return all_templates
  297. def find_by_id(self, module_name, files, template_id):
  298. """
  299. Find a template by its ID across all libraries.
  300. Args:
  301. module_name: The module name (e.g., 'terraform', 'compose') to search within.
  302. This narrows the search to the specific technology directory across all libraries,
  303. allowing for modular organization of templates by technology type.
  304. files: List of file patterns to search for (e.g., ['*.tf', '*.yaml']).
  305. This filters templates to only those with matching file extensions,
  306. ensuring we only process relevant template files for the specific module type.
  307. template_id: The unique identifier of the template to find.
  308. This is typically derived from the template's directory name or filename,
  309. providing a human-readable way to reference specific templates.
  310. Returns:
  311. Template object if found across any library, None otherwise.
  312. Note:
  313. This method searches through all registered libraries in priority order (highest first),
  314. returning the first matching template found. This allows higher-priority libraries
  315. to override templates from lower-priority ones.
  316. """
  317. logger.debug(f"Searching for template '{template_id}' across {len(self.libraries)} libraries (module: {module_name})")
  318. for library in self.libraries: # Already sorted by priority
  319. template = library.find_by_id(module_name, files, template_id)
  320. if template:
  321. logger.info(f"Retrieved template '{template_id}' from library '{library.name}' (priority: {library.priority})")
  322. return template
  323. logger.warning(f"Template '{template_id}' not found in any library")
  324. return None