config.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import re
  5. import shutil
  6. import tempfile
  7. from pathlib import Path
  8. from typing import Any, Dict, Optional, Union
  9. import yaml
  10. from rich.console import Console
  11. from .variables import Variable, VariableSection, VariableCollection
  12. logger = logging.getLogger(__name__)
  13. console = Console()
  14. # Valid Python identifier pattern for variable names
  15. VALID_IDENTIFIER_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
  16. class ConfigManager:
  17. """Manages configuration for the CLI application."""
  18. def __init__(self, config_path: Optional[Union[str, Path]] = None) -> None:
  19. """Initialize the configuration manager.
  20. Args:
  21. config_path: Path to the configuration file. If None, uses default location.
  22. """
  23. if config_path is None:
  24. # Default to ~/.config/boilerplates/config.yaml
  25. config_dir = Path.home() / ".config" / "boilerplates"
  26. config_dir.mkdir(parents=True, exist_ok=True)
  27. self.config_path = config_dir / "config.yaml"
  28. else:
  29. self.config_path = Path(config_path)
  30. # Create default config if it doesn't exist
  31. if not self.config_path.exists():
  32. self._create_default_config()
  33. def _create_default_config(self) -> None:
  34. """Create a default configuration file."""
  35. default_config = {
  36. "defaults": {},
  37. "preferences": {
  38. "editor": "vim",
  39. "output_dir": None,
  40. "library_paths": []
  41. }
  42. }
  43. self._write_config(default_config)
  44. logger.info(f"Created default configuration at {self.config_path}")
  45. def _read_config(self) -> Dict[str, Any]:
  46. """Read configuration from file.
  47. Returns:
  48. Dictionary containing the configuration.
  49. Raises:
  50. yaml.YAMLError: If YAML parsing fails.
  51. ValueError: If configuration structure is invalid.
  52. """
  53. try:
  54. with open(self.config_path, 'r') as f:
  55. config = yaml.safe_load(f) or {}
  56. # Validate config structure
  57. self._validate_config_structure(config)
  58. return config
  59. except yaml.YAMLError as e:
  60. logger.error(f"Failed to parse YAML configuration: {e}")
  61. raise
  62. except ValueError as e:
  63. logger.error(f"Invalid configuration structure: {e}")
  64. raise
  65. except Exception as e:
  66. logger.error(f"Failed to read configuration file: {e}")
  67. raise
  68. def _write_config(self, config: Dict[str, Any]) -> None:
  69. """Write configuration to file atomically using temp file + rename pattern.
  70. This prevents config file corruption if write operation fails partway through.
  71. Args:
  72. config: Dictionary containing the configuration to write.
  73. Raises:
  74. ValueError: If configuration structure is invalid.
  75. """
  76. try:
  77. # Validate config structure before writing
  78. self._validate_config_structure(config)
  79. # Ensure parent directory exists
  80. self.config_path.parent.mkdir(parents=True, exist_ok=True)
  81. # Write to temporary file in same directory for atomic rename
  82. with tempfile.NamedTemporaryFile(
  83. mode='w',
  84. delete=False,
  85. dir=self.config_path.parent,
  86. prefix='.config_',
  87. suffix='.tmp'
  88. ) as tmp_file:
  89. yaml.dump(config, tmp_file, default_flow_style=False)
  90. tmp_path = tmp_file.name
  91. # Atomic rename (overwrites existing file on POSIX systems)
  92. shutil.move(tmp_path, self.config_path)
  93. logger.debug(f"Configuration written atomically to {self.config_path}")
  94. except ValueError as e:
  95. logger.error(f"Invalid configuration structure: {e}")
  96. raise
  97. except Exception as e:
  98. # Clean up temp file if it exists
  99. if 'tmp_path' in locals():
  100. try:
  101. Path(tmp_path).unlink(missing_ok=True)
  102. except Exception:
  103. pass
  104. logger.error(f"Failed to write configuration file: {e}")
  105. raise
  106. def _validate_config_structure(self, config: Dict[str, Any]) -> None:
  107. """Validate the configuration structure.
  108. Args:
  109. config: Configuration dictionary to validate.
  110. Raises:
  111. ValueError: If configuration structure is invalid.
  112. """
  113. if not isinstance(config, dict):
  114. raise ValueError("Configuration must be a dictionary")
  115. # Check top-level structure
  116. if "defaults" in config and not isinstance(config["defaults"], dict):
  117. raise ValueError("'defaults' must be a dictionary")
  118. if "preferences" in config and not isinstance(config["preferences"], dict):
  119. raise ValueError("'preferences' must be a dictionary")
  120. # Validate defaults structure
  121. if "defaults" in config:
  122. for module_name, module_defaults in config["defaults"].items():
  123. if not isinstance(module_name, str):
  124. raise ValueError(f"Module name must be a string, got {type(module_name).__name__}")
  125. if not isinstance(module_defaults, dict):
  126. raise ValueError(f"Defaults for module '{module_name}' must be a dictionary")
  127. # Validate variable names are valid Python identifiers
  128. for var_name in module_defaults.keys():
  129. if not isinstance(var_name, str):
  130. raise ValueError(f"Variable name must be a string, got {type(var_name).__name__}")
  131. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  132. raise ValueError(
  133. f"Invalid variable name '{var_name}' in module '{module_name}'. "
  134. f"Variable names must be valid Python identifiers (letters, numbers, underscores, "
  135. f"cannot start with a number)"
  136. )
  137. # Validate preferences structure and types
  138. if "preferences" in config:
  139. preferences = config["preferences"]
  140. # Validate known preference types
  141. if "editor" in preferences and not isinstance(preferences["editor"], str):
  142. raise ValueError("Preference 'editor' must be a string")
  143. if "output_dir" in preferences:
  144. if preferences["output_dir"] is not None and not isinstance(preferences["output_dir"], str):
  145. raise ValueError("Preference 'output_dir' must be a string or null")
  146. if "library_paths" in preferences:
  147. if not isinstance(preferences["library_paths"], list):
  148. raise ValueError("Preference 'library_paths' must be a list")
  149. for path in preferences["library_paths"]:
  150. if not isinstance(path, str):
  151. raise ValueError(f"Library path must be a string, got {type(path).__name__}")
  152. def get_config_path(self) -> Path:
  153. """Get the path to the configuration file.
  154. Returns:
  155. Path to the configuration file.
  156. """
  157. return self.config_path
  158. def get_defaults(self, module_name: str) -> Dict[str, Any]:
  159. """Get default variable values for a module.
  160. Returns defaults in a flat format:
  161. {
  162. "var_name": "value",
  163. "var2_name": "value2"
  164. }
  165. Args:
  166. module_name: Name of the module
  167. Returns:
  168. Dictionary of default values (flat key-value pairs)
  169. """
  170. config = self._read_config()
  171. defaults = config.get("defaults", {})
  172. return defaults.get(module_name, {})
  173. def set_defaults(self, module_name: str, defaults: Dict[str, Any]) -> None:
  174. """Set default variable values for a module.
  175. Args:
  176. module_name: Name of the module
  177. defaults: Dictionary of defaults (flat key-value pairs):
  178. {"var_name": "value", "var2_name": "value2"}
  179. Raises:
  180. ValueError: If module name or variable names are invalid.
  181. """
  182. # Validate module name
  183. if not isinstance(module_name, str) or not module_name:
  184. raise ValueError("Module name must be a non-empty string")
  185. # Validate defaults dictionary
  186. if not isinstance(defaults, dict):
  187. raise ValueError("Defaults must be a dictionary")
  188. # Validate variable names
  189. for var_name in defaults.keys():
  190. if not isinstance(var_name, str):
  191. raise ValueError(f"Variable name must be a string, got {type(var_name).__name__}")
  192. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  193. raise ValueError(
  194. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  195. f"(letters, numbers, underscores, cannot start with a number)"
  196. )
  197. config = self._read_config()
  198. if "defaults" not in config:
  199. config["defaults"] = {}
  200. config["defaults"][module_name] = defaults
  201. self._write_config(config)
  202. logger.info(f"Updated defaults for module '{module_name}'")
  203. def set_default_value(self, module_name: str, var_name: str, value: Any) -> None:
  204. """Set a single default variable value.
  205. Args:
  206. module_name: Name of the module
  207. var_name: Name of the variable
  208. value: Default value to set
  209. Raises:
  210. ValueError: If module name or variable name is invalid.
  211. """
  212. # Validate inputs
  213. if not isinstance(module_name, str) or not module_name:
  214. raise ValueError("Module name must be a non-empty string")
  215. if not isinstance(var_name, str):
  216. raise ValueError(f"Variable name must be a string, got {type(var_name).__name__}")
  217. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  218. raise ValueError(
  219. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  220. f"(letters, numbers, underscores, cannot start with a number)"
  221. )
  222. defaults = self.get_defaults(module_name)
  223. defaults[var_name] = value
  224. self.set_defaults(module_name, defaults)
  225. logger.info(f"Set default for '{module_name}.{var_name}' = '{value}'")
  226. def get_default_value(self, module_name: str, var_name: str) -> Optional[Any]:
  227. """Get a single default variable value.
  228. Args:
  229. module_name: Name of the module
  230. var_name: Name of the variable
  231. Returns:
  232. Default value or None if not set
  233. """
  234. defaults = self.get_defaults(module_name)
  235. return defaults.get(var_name)
  236. def clear_defaults(self, module_name: str) -> None:
  237. """Clear all defaults for a module.
  238. Args:
  239. module_name: Name of the module
  240. """
  241. config = self._read_config()
  242. if "defaults" in config and module_name in config["defaults"]:
  243. del config["defaults"][module_name]
  244. self._write_config(config)
  245. logger.info(f"Cleared defaults for module '{module_name}'")
  246. def get_preference(self, key: str) -> Optional[Any]:
  247. """Get a user preference value.
  248. Args:
  249. key: Preference key (e.g., 'editor', 'output_dir', 'library_paths')
  250. Returns:
  251. Preference value or None if not set
  252. """
  253. config = self._read_config()
  254. preferences = config.get("preferences", {})
  255. return preferences.get(key)
  256. def set_preference(self, key: str, value: Any) -> None:
  257. """Set a user preference value.
  258. Args:
  259. key: Preference key
  260. value: Preference value
  261. Raises:
  262. ValueError: If key or value is invalid for known preference types.
  263. """
  264. # Validate key
  265. if not isinstance(key, str) or not key:
  266. raise ValueError("Preference key must be a non-empty string")
  267. # Validate known preference types
  268. if key == "editor" and not isinstance(value, str):
  269. raise ValueError("Preference 'editor' must be a string")
  270. if key == "output_dir":
  271. if value is not None and not isinstance(value, str):
  272. raise ValueError("Preference 'output_dir' must be a string or null")
  273. if key == "library_paths":
  274. if not isinstance(value, list):
  275. raise ValueError("Preference 'library_paths' must be a list")
  276. for path in value:
  277. if not isinstance(path, str):
  278. raise ValueError(f"Library path must be a string, got {type(path).__name__}")
  279. config = self._read_config()
  280. if "preferences" not in config:
  281. config["preferences"] = {}
  282. config["preferences"][key] = value
  283. self._write_config(config)
  284. logger.info(f"Set preference '{key}' = '{value}'")
  285. def get_all_preferences(self) -> Dict[str, Any]:
  286. """Get all user preferences.
  287. Returns:
  288. Dictionary of all preferences
  289. """
  290. config = self._read_config()
  291. return config.get("preferences", {})