config_manager.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. from __future__ import annotations
  2. import logging
  3. import shutil
  4. import tempfile
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. from typing import Any, ClassVar
  8. from urllib.parse import urlparse
  9. import yaml
  10. from ..exceptions import ConfigError, ConfigValidationError, YAMLParseError
  11. logger = logging.getLogger(__name__)
  12. DEFAULT_LIBRARY_NAME = "default"
  13. DEFAULT_LIBRARY_DIRECTORY = "."
  14. DEFAULT_LIBRARY_BRANCH = "main"
  15. DEFAULT_LIBRARY_URL = "https://github.com/christianlempa/boilerplates-library.git"
  16. LEGACY_DEFAULT_LIBRARY_DIRECTORY = "library"
  17. LEGACY_DEFAULT_LIBRARY_URL = "https://github.com/christianlempa/boilerplates.git"
  18. LEGACY_DEFAULT_LIBRARY_REPO = "github.com/christianlempa/boilerplates"
  19. def normalize_git_url(url: str | None) -> str:
  20. """Normalize git URLs so SSH/HTTPS variants can be compared safely."""
  21. if not url:
  22. return ""
  23. candidate = url.strip()
  24. if not candidate:
  25. return ""
  26. if candidate.startswith("git@"):
  27. host_and_path = candidate[4:]
  28. host, _, path = host_and_path.partition(":")
  29. normalized = f"{host}/{path}"
  30. elif "://" in candidate:
  31. parsed = urlparse(candidate)
  32. normalized = f"{parsed.netloc}{parsed.path}"
  33. else:
  34. normalized = candidate
  35. normalized = normalized.rstrip("/")
  36. if normalized.endswith(".git"):
  37. normalized = normalized[:-4]
  38. if normalized.startswith("www."):
  39. normalized = normalized[4:]
  40. return normalized.lower()
  41. def is_legacy_default_library_url(url: str | None) -> bool:
  42. """Return True when a URL points at the legacy christianlempa/boilerplates repo."""
  43. return normalize_git_url(url) == LEGACY_DEFAULT_LIBRARY_REPO
  44. @dataclass
  45. class MigrationNotice:
  46. """Represents a user-visible config migration notice."""
  47. kind: str
  48. message: str
  49. @dataclass
  50. class LibraryConfig:
  51. """Configuration for a template library."""
  52. name: str
  53. library_type: str = "git"
  54. url: str | None = None
  55. directory: str | None = None
  56. branch: str = "main"
  57. path: str | None = None
  58. enabled: bool = True
  59. class ConfigManager:
  60. """Manages configuration for the CLI application."""
  61. _pending_migration_notices: ClassVar[list[MigrationNotice]] = []
  62. def __init__(self, config_path: str | Path | None = None) -> None:
  63. """Initialize the configuration manager.
  64. Args:
  65. config_path: Path to the configuration file. If None, auto-detects:
  66. 1. Checks for ./config.yaml (local project config)
  67. 2. Falls back to ~/.config/boilerplates/config.yaml (global config)
  68. """
  69. if config_path is None:
  70. # Check for local config.yaml in current directory first
  71. local_config = Path.cwd() / "config.yaml"
  72. if local_config.exists() and local_config.is_file():
  73. self.config_path = local_config
  74. self.is_local = True
  75. logger.debug(f"Using local config: {local_config}")
  76. else:
  77. # Fall back to global config
  78. config_dir = Path.home() / ".config" / "boilerplates"
  79. config_dir.mkdir(parents=True, exist_ok=True)
  80. self.config_path = config_dir / "config.yaml"
  81. self.is_local = False
  82. else:
  83. self.config_path = Path(config_path)
  84. self.is_local = False
  85. # Create default config if it doesn't exist (only for global config)
  86. if not self.config_path.exists():
  87. if not self.is_local:
  88. self._create_default_config()
  89. else:
  90. raise ConfigError(f"Local config file not found: {self.config_path}")
  91. else:
  92. # Migrate existing config if needed
  93. self._migrate_config_if_needed()
  94. def _create_default_config(self) -> None:
  95. """Create a default configuration file."""
  96. default_config = {
  97. "defaults": {},
  98. "preferences": {"editor": "vim", "output_dir": None, "library_paths": []},
  99. "libraries": [
  100. {
  101. "name": DEFAULT_LIBRARY_NAME,
  102. "type": "git",
  103. "url": DEFAULT_LIBRARY_URL,
  104. "branch": DEFAULT_LIBRARY_BRANCH,
  105. "directory": DEFAULT_LIBRARY_DIRECTORY,
  106. "enabled": True,
  107. }
  108. ],
  109. }
  110. self._write_config(default_config)
  111. logger.info(f"Created default configuration at {self.config_path}")
  112. def _migrate_config_if_needed(self) -> None:
  113. """Migrate existing config to add missing sections and library types."""
  114. try:
  115. config = self._read_config()
  116. needs_migration = False
  117. # Add libraries section if missing
  118. if "libraries" not in config:
  119. logger.info("Migrating config: adding libraries section")
  120. config["libraries"] = [
  121. {
  122. "name": DEFAULT_LIBRARY_NAME,
  123. "type": "git",
  124. "url": DEFAULT_LIBRARY_URL,
  125. "branch": DEFAULT_LIBRARY_BRANCH,
  126. "directory": DEFAULT_LIBRARY_DIRECTORY,
  127. "enabled": True,
  128. }
  129. ]
  130. needs_migration = True
  131. else:
  132. # Migrate existing libraries to add 'type' field if missing
  133. # For backward compatibility, assume all old libraries without
  134. # 'type' are git libraries
  135. libraries = config.get("libraries", [])
  136. for library in libraries:
  137. if "type" not in library:
  138. lib_name = library.get("name", "unknown")
  139. logger.info(f"Migrating library '{lib_name}': adding type: git")
  140. library["type"] = "git"
  141. needs_migration = True
  142. boilerplates_repo_migrated = self._migrate_boilerplates_repo_libraries(config)
  143. needs_migration = needs_migration or boilerplates_repo_migrated
  144. # Write back if migration was needed
  145. if needs_migration:
  146. self._write_config(config)
  147. logger.info("Config migration completed successfully")
  148. except (ConfigError, ConfigValidationError, YAMLParseError):
  149. raise
  150. except Exception as e:
  151. logger.error(f"Config migration failed: {e}")
  152. raise ConfigError(f"Failed to migrate configuration '{self.config_path}': {e}") from e
  153. def _migrate_boilerplates_repo_libraries(self, config: dict[str, Any]) -> bool:
  154. """Rewrite any legacy christianlempa/boilerplates library entry to the new repo."""
  155. libraries = config.get("libraries", [])
  156. migrated = False
  157. migrated_libraries: list[str] = []
  158. for library in libraries:
  159. if library.get("type", "git") != "git":
  160. continue
  161. if not is_legacy_default_library_url(library.get("url")):
  162. continue
  163. target_state = {
  164. "url": DEFAULT_LIBRARY_URL,
  165. "directory": DEFAULT_LIBRARY_DIRECTORY,
  166. }
  167. changed = any(library.get(key) != value for key, value in target_state.items())
  168. if not changed:
  169. continue
  170. library_name = library.get("name", DEFAULT_LIBRARY_NAME)
  171. previous_location = library.get("url") or library.get("path") or "<unknown>"
  172. library.update(target_state)
  173. migrated = True
  174. migrated_libraries.append(library_name)
  175. logger.info(
  176. "Migrated library '%s' from %s to %s (%s)",
  177. library_name,
  178. previous_location,
  179. DEFAULT_LIBRARY_URL,
  180. DEFAULT_LIBRARY_DIRECTORY,
  181. )
  182. if migrated:
  183. migrated_names = ", ".join(sorted(migrated_libraries))
  184. self._add_migration_notice(
  185. kind="default_library_repo",
  186. message=(
  187. "Migrated template library configuration"
  188. f" ({migrated_names}) from christianlempa/boilerplates to "
  189. "christianlempa/boilerplates-library. "
  190. "Run 'boilerplates repo update' to resync the managed library checkout."
  191. ),
  192. )
  193. return migrated
  194. @classmethod
  195. def _add_migration_notice(cls, kind: str, message: str) -> None:
  196. """Queue a migration notice for later display in the CLI layer."""
  197. if any(notice.kind == kind and notice.message == message for notice in cls._pending_migration_notices):
  198. return
  199. cls._pending_migration_notices.append(MigrationNotice(kind=kind, message=message))
  200. @classmethod
  201. def consume_migration_notices(cls) -> list[MigrationNotice]:
  202. """Return and clear pending migration notices."""
  203. notices = cls._pending_migration_notices.copy()
  204. cls._pending_migration_notices.clear()
  205. return notices
  206. def _read_config(self) -> dict[str, Any]:
  207. """Read configuration from file.
  208. Returns:
  209. Dictionary containing the configuration.
  210. Raises:
  211. YAMLParseError: If YAML parsing fails.
  212. ConfigValidationError: If configuration structure is invalid.
  213. ConfigError: If reading fails for other reasons.
  214. """
  215. try:
  216. with self.config_path.open() as f:
  217. config = yaml.safe_load(f) or {}
  218. # Validate config structure
  219. self._validate_config_structure(config)
  220. return config
  221. except yaml.YAMLError as e:
  222. logger.error(f"Failed to parse YAML configuration: {e}")
  223. raise YAMLParseError(str(self.config_path), e) from e
  224. except ConfigValidationError:
  225. # Re-raise validation errors as-is
  226. raise
  227. except OSError as e:
  228. logger.error(f"Failed to read configuration file: {e}")
  229. raise ConfigError(f"Failed to read configuration file '{self.config_path}': {e}") from e
  230. def _write_config(self, config: dict[str, Any]) -> None:
  231. """Write configuration to file atomically using temp file + rename pattern.
  232. This prevents config file corruption if write operation fails partway through.
  233. Args:
  234. config: Dictionary containing the configuration to write.
  235. Raises:
  236. ConfigValidationError: If configuration structure is invalid.
  237. ConfigError: If writing fails for any reason.
  238. """
  239. tmp_path = None
  240. try:
  241. # Validate config structure before writing
  242. self._validate_config_structure(config)
  243. # Ensure parent directory exists
  244. self.config_path.parent.mkdir(parents=True, exist_ok=True)
  245. # Write to temporary file in same directory for atomic rename
  246. with tempfile.NamedTemporaryFile(
  247. mode="w",
  248. delete=False,
  249. dir=self.config_path.parent,
  250. prefix=".config_",
  251. suffix=".tmp",
  252. ) as tmp_file:
  253. yaml.dump(config, tmp_file, default_flow_style=False)
  254. tmp_path = tmp_file.name
  255. # Atomic rename (overwrites existing file on POSIX systems)
  256. shutil.move(tmp_path, self.config_path)
  257. logger.debug(f"Configuration written atomically to {self.config_path}")
  258. except ConfigValidationError:
  259. # Re-raise validation errors as-is
  260. if tmp_path:
  261. Path(tmp_path).unlink(missing_ok=True)
  262. raise
  263. except (OSError, yaml.YAMLError) as e:
  264. # Clean up temp file if it exists
  265. if tmp_path:
  266. try:
  267. Path(tmp_path).unlink(missing_ok=True)
  268. except OSError:
  269. logger.warning(f"Failed to clean up temporary file: {tmp_path}")
  270. logger.error(f"Failed to write configuration file: {e}")
  271. raise ConfigError(f"Failed to write configuration to '{self.config_path}': {e}") from e
  272. def _validate_config_structure(self, config: dict[str, Any]) -> None:
  273. """Validate the configuration structure - basic type checking.
  274. Args:
  275. config: Configuration dictionary to validate.
  276. Raises:
  277. ConfigValidationError: If configuration structure is invalid.
  278. """
  279. if not isinstance(config, dict):
  280. raise ConfigValidationError("Configuration must be a dictionary")
  281. # Validate top-level types
  282. self._validate_top_level_types(config)
  283. # Validate defaults structure
  284. self._validate_defaults_types(config)
  285. # Validate libraries structure
  286. self._validate_libraries_fields(config)
  287. def _validate_top_level_types(self, config: dict[str, Any]) -> None:
  288. """Validate top-level config section types."""
  289. if "defaults" in config and not isinstance(config["defaults"], dict):
  290. raise ConfigValidationError("'defaults' must be a dictionary")
  291. if "preferences" in config and not isinstance(config["preferences"], dict):
  292. raise ConfigValidationError("'preferences' must be a dictionary")
  293. if "libraries" in config and not isinstance(config["libraries"], list):
  294. raise ConfigValidationError("'libraries' must be a list")
  295. def _validate_defaults_types(self, config: dict[str, Any]) -> None:
  296. """Validate defaults section has correct types."""
  297. if "defaults" not in config:
  298. return
  299. for module_name, module_defaults in config["defaults"].items():
  300. if not isinstance(module_defaults, dict):
  301. raise ConfigValidationError(f"Defaults for module '{module_name}' must be a dictionary")
  302. def _validate_libraries_fields(self, config: dict[str, Any]) -> None:
  303. """Validate libraries have required fields."""
  304. if "libraries" not in config:
  305. return
  306. for i, library in enumerate(config["libraries"]):
  307. if not isinstance(library, dict):
  308. raise ConfigValidationError(f"Library at index {i} must be a dictionary")
  309. if "name" not in library:
  310. raise ConfigValidationError(f"Library at index {i} missing required field 'name'")
  311. lib_type = library.get("type", "git")
  312. if lib_type == "git" and ("url" not in library or "directory" not in library):
  313. raise ConfigValidationError(
  314. f"Git library at index {i} missing required fields 'url' and/or 'directory'"
  315. )
  316. if lib_type == "static" and "path" not in library:
  317. raise ConfigValidationError(f"Static library at index {i} missing required field 'path'")
  318. def get_config_path(self) -> Path:
  319. """Get the path to the configuration file being used.
  320. Returns:
  321. Path to the configuration file (global or local).
  322. """
  323. return self.config_path
  324. def is_using_local_config(self) -> bool:
  325. """Check if a local configuration file is being used.
  326. Returns:
  327. True if using local config, False if using global config.
  328. """
  329. return self.is_local
  330. def get_defaults(self, module_name: str) -> dict[str, Any]:
  331. """Get default variable values for a module.
  332. Returns defaults in a flat format:
  333. {
  334. "var_name": "value",
  335. "var2_name": "value2"
  336. }
  337. Args:
  338. module_name: Name of the module
  339. Returns:
  340. Dictionary of default values (flat key-value pairs)
  341. """
  342. config = self._read_config()
  343. defaults = config.get("defaults", {})
  344. return defaults.get(module_name, {})
  345. def set_defaults(self, module_name: str, defaults: dict[str, Any]) -> None:
  346. """Set default variable values for a module with comprehensive validation.
  347. Args:
  348. module_name: Name of the module
  349. defaults: Dictionary of defaults (flat key-value pairs):
  350. {"var_name": "value", "var2_name": "value2"}
  351. Raises:
  352. ConfigValidationError: If module name or variable names are invalid.
  353. """
  354. # Basic validation
  355. if not isinstance(module_name, str) or not module_name:
  356. raise ConfigValidationError("Module name must be a non-empty string")
  357. if not isinstance(defaults, dict):
  358. raise ConfigValidationError("Defaults must be a dictionary")
  359. config = self._read_config()
  360. if "defaults" not in config:
  361. config["defaults"] = {}
  362. config["defaults"][module_name] = defaults
  363. self._write_config(config)
  364. logger.info(f"Updated defaults for module '{module_name}'")
  365. def set_default_value(self, module_name: str, var_name: str, value: Any) -> None:
  366. """Set a single default variable value with comprehensive validation.
  367. Args:
  368. module_name: Name of the module
  369. var_name: Name of the variable
  370. value: Default value to set
  371. Raises:
  372. ConfigValidationError: If module name or variable name is invalid.
  373. """
  374. # Basic validation
  375. if not isinstance(module_name, str) or not module_name:
  376. raise ConfigValidationError("Module name must be a non-empty string")
  377. if not isinstance(var_name, str) or not var_name:
  378. raise ConfigValidationError("Variable name must be a non-empty string")
  379. defaults = self.get_defaults(module_name)
  380. defaults[var_name] = value
  381. self.set_defaults(module_name, defaults)
  382. logger.info(f"Set default for '{module_name}.{var_name}' = '{value}'")
  383. def get_default_value(self, module_name: str, var_name: str) -> Any | None:
  384. """Get a single default variable value.
  385. Args:
  386. module_name: Name of the module
  387. var_name: Name of the variable
  388. Returns:
  389. Default value or None if not set
  390. """
  391. defaults = self.get_defaults(module_name)
  392. return defaults.get(var_name)
  393. def clear_defaults(self, module_name: str) -> None:
  394. """Clear all defaults for a module.
  395. Args:
  396. module_name: Name of the module
  397. """
  398. config = self._read_config()
  399. if "defaults" in config and module_name in config["defaults"]:
  400. del config["defaults"][module_name]
  401. self._write_config(config)
  402. logger.info(f"Cleared defaults for module '{module_name}'")
  403. def get_preference(self, key: str) -> Any | None:
  404. """Get a user preference value.
  405. Args:
  406. key: Preference key (e.g., 'editor', 'output_dir', 'library_paths')
  407. Returns:
  408. Preference value or None if not set
  409. """
  410. config = self._read_config()
  411. preferences = config.get("preferences", {})
  412. return preferences.get(key)
  413. def set_preference(self, key: str, value: Any) -> None:
  414. """Set a user preference value with comprehensive validation.
  415. Args:
  416. key: Preference key
  417. value: Preference value
  418. Raises:
  419. ConfigValidationError: If key or value is invalid for known preference types.
  420. """
  421. # Basic validation
  422. if not isinstance(key, str) or not key:
  423. raise ConfigValidationError("Preference key must be a non-empty string")
  424. config = self._read_config()
  425. if "preferences" not in config:
  426. config["preferences"] = {}
  427. config["preferences"][key] = value
  428. self._write_config(config)
  429. logger.info(f"Set preference '{key}' = '{value}'")
  430. def get_all_preferences(self) -> dict[str, Any]:
  431. """Get all user preferences.
  432. Returns:
  433. Dictionary of all preferences
  434. """
  435. config = self._read_config()
  436. return config.get("preferences", {})
  437. def get_libraries(self) -> list[dict[str, Any]]:
  438. """Get all configured libraries.
  439. Returns:
  440. List of library configurations
  441. """
  442. config = self._read_config()
  443. return config.get("libraries", [])
  444. def get_library_by_name(self, name: str) -> dict[str, Any] | None:
  445. """Get a specific library by name.
  446. Args:
  447. name: Name of the library
  448. Returns:
  449. Library configuration dictionary or None if not found
  450. """
  451. libraries = self.get_libraries()
  452. for library in libraries:
  453. if library.get("name") == name:
  454. return library
  455. return None
  456. def add_library(self, lib_config: LibraryConfig) -> None:
  457. """Add a new library to the configuration.
  458. Args:
  459. lib_config: Library configuration
  460. Raises:
  461. ConfigValidationError: If library with the same name already exists or validation fails
  462. """
  463. # Basic validation
  464. if not isinstance(lib_config.name, str) or not lib_config.name:
  465. raise ConfigValidationError("Library name must be a non-empty string")
  466. if lib_config.library_type not in ("git", "static"):
  467. raise ConfigValidationError(f"Library type must be 'git' or 'static', got '{lib_config.library_type}'")
  468. if self.get_library_by_name(lib_config.name):
  469. raise ConfigValidationError(f"Library '{lib_config.name}' already exists")
  470. # Type-specific validation
  471. if lib_config.library_type == "git":
  472. if not lib_config.url or not lib_config.directory:
  473. raise ConfigValidationError("Git libraries require 'url' and 'directory' parameters")
  474. library_dict = {
  475. "name": lib_config.name,
  476. "type": "git",
  477. "url": lib_config.url,
  478. "branch": lib_config.branch,
  479. "directory": lib_config.directory,
  480. "enabled": lib_config.enabled,
  481. }
  482. else: # static
  483. if not lib_config.path:
  484. raise ConfigValidationError("Static libraries require 'path' parameter")
  485. # For backward compatibility with older CLI versions,
  486. # add dummy values for git-specific fields
  487. library_dict = {
  488. "name": lib_config.name,
  489. "type": "static",
  490. "url": "", # Empty string for backward compatibility
  491. "branch": "main", # Default value for backward compatibility
  492. "directory": ".", # Default value for backward compatibility
  493. "path": lib_config.path,
  494. "enabled": lib_config.enabled,
  495. }
  496. config = self._read_config()
  497. if "libraries" not in config:
  498. config["libraries"] = []
  499. config["libraries"].append(library_dict)
  500. self._write_config(config)
  501. logger.info(f"Added {lib_config.library_type} library '{lib_config.name}'")
  502. def remove_library(self, name: str) -> None:
  503. """Remove a library from the configuration.
  504. Args:
  505. name: Name of the library to remove
  506. Raises:
  507. ConfigError: If library is not found
  508. """
  509. config = self._read_config()
  510. libraries = config.get("libraries", [])
  511. # Find and remove the library
  512. new_libraries = [lib for lib in libraries if lib.get("name") != name]
  513. if len(new_libraries) == len(libraries):
  514. raise ConfigError(f"Library '{name}' not found")
  515. config["libraries"] = new_libraries
  516. self._write_config(config)
  517. logger.info(f"Removed library '{name}'")
  518. def update_library(self, name: str, **kwargs: Any) -> None:
  519. """Update a library's configuration.
  520. Args:
  521. name: Name of the library to update
  522. **kwargs: Fields to update (url, branch, directory, enabled)
  523. Raises:
  524. ConfigError: If library is not found
  525. ConfigValidationError: If validation fails
  526. """
  527. config = self._read_config()
  528. libraries = config.get("libraries", [])
  529. # Find the library
  530. library_found = False
  531. for library in libraries:
  532. if library.get("name") == name:
  533. library_found = True
  534. # Update allowed fields
  535. if "url" in kwargs:
  536. library["url"] = kwargs["url"]
  537. if "branch" in kwargs:
  538. library["branch"] = kwargs["branch"]
  539. if "directory" in kwargs:
  540. library["directory"] = kwargs["directory"]
  541. if "enabled" in kwargs:
  542. library["enabled"] = kwargs["enabled"]
  543. break
  544. if not library_found:
  545. raise ConfigError(f"Library '{name}' not found")
  546. config["libraries"] = libraries
  547. self._write_config(config)
  548. logger.info(f"Updated library '{name}'")
  549. def get_libraries_path(self) -> Path:
  550. """Get the path to the libraries directory.
  551. Returns:
  552. Path to the libraries directory (same directory as config file)
  553. """
  554. return self.config_path.parent / "libraries"