config_manager.py 24 KB

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