library.py 11 KB

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