config_handler.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. """
  2. Plugin Configuration Handler with Auto-Type Casting
  3. This module provides comprehensive plugin configuration management with:
  4. - Auto-loading and saving of JSON configuration files
  5. - Intelligent type casting (int, bool, float, string preservation)
  6. - Configuration validation and schema support
  7. - Thread-safe configuration access
  8. - Configuration change notifications
  9. - Default value management
  10. - Environment variable integration
  11. - Configuration backup and recovery
  12. Features:
  13. - Automatically detects and casts configuration values to appropriate types
  14. - Handles boolean strings ("true", "false", "yes", "no", "1", "0")
  15. - Preserves original types when possible
  16. - Supports nested configuration structures
  17. - Validates configuration against schemas
  18. - Thread-safe read/write operations
  19. - Configuration change callbacks
  20. - Backup and rollback functionality
  21. """
  22. import os
  23. import json
  24. import threading
  25. import time
  26. import copy
  27. from typing import Any, Dict, Optional, Union, Type, Callable, List, Set
  28. from pathlib import Path
  29. from dataclasses import dataclass, field
  30. from enum import Enum
  31. # Optional jsonschema import for validation
  32. try:
  33. import jsonschema
  34. from jsonschema import validate, ValidationError
  35. HAS_JSONSCHEMA = True
  36. except ImportError:
  37. HAS_JSONSCHEMA = False
  38. ValidationError = Exception
  39. def pprint(message: str) -> None:
  40. """Configuration handler logging function."""
  41. print(f"[CONFIG] {message}")
  42. class ConfigValueType(Enum):
  43. """Configuration value types."""
  44. STRING = "string"
  45. INTEGER = "integer"
  46. FLOAT = "float"
  47. BOOLEAN = "boolean"
  48. LIST = "list"
  49. DICT = "dict"
  50. NULL = "null"
  51. @dataclass
  52. class ConfigValue:
  53. """
  54. Represents a configuration value with type information and metadata.
  55. """
  56. key: str
  57. value: Any
  58. original_type: ConfigValueType
  59. cast_type: Optional[ConfigValueType] = None
  60. default_value: Any = None
  61. description: str = ""
  62. required: bool = False
  63. validation_rules: Dict[str, Any] = field(default_factory=dict)
  64. last_modified: float = field(default_factory=time.time)
  65. def is_modified(self) -> bool:
  66. """Check if value differs from default."""
  67. return self.value != self.default_value
  68. def reset_to_default(self) -> None:
  69. """Reset value to default."""
  70. self.value = copy.deepcopy(self.default_value)
  71. self.last_modified = time.time()
  72. class ConfigHandlerError(Exception):
  73. """Base exception for configuration handler errors."""
  74. pass
  75. class ConfigValidationError(ConfigHandlerError):
  76. """Raised when configuration validation fails."""
  77. pass
  78. class ConfigLoadError(ConfigHandlerError):
  79. """Raised when configuration cannot be loaded."""
  80. pass
  81. class ConfigSaveError(ConfigHandlerError):
  82. """Raised when configuration cannot be saved."""
  83. pass
  84. class PluginConfigHandler:
  85. """
  86. Plugin configuration handler with advanced type casting and validation.
  87. This class manages plugin configuration with:
  88. - Intelligent type detection and casting
  89. - Thread-safe access to configuration
  90. - Schema-based validation
  91. - Change notifications
  92. - Backup and recovery
  93. - Environment variable integration
  94. Usage:
  95. # Create handler
  96. config_handler = PluginConfigHandler(
  97. config_path=Path("./plugins/my_plugin/config.json"),
  98. plugin_name="my_plugin"
  99. )
  100. # Load configuration
  101. config = config_handler.load_config()
  102. # Get values with type casting
  103. enabled = config_handler.get_bool("enabled", default=True)
  104. port = config_handler.get_int("port", default=8080)
  105. name = config_handler.get_string("name", default="My Plugin")
  106. # Set values
  107. config_handler.set_value("enabled", False)
  108. config_handler.set_value("port", 9090)
  109. # Save configuration
  110. config_handler.save_config()
  111. """
  112. def __init__(
  113. self,
  114. config_path: Path,
  115. plugin_name: str,
  116. auto_save: bool = True,
  117. backup_enabled: bool = True,
  118. schema: Optional[Dict[str, Any]] = None
  119. ):
  120. """
  121. Initialize configuration handler.
  122. Args:
  123. config_path: Path to the configuration file
  124. plugin_name: Name of the plugin
  125. auto_save: Automatically save when values change
  126. backup_enabled: Create backups before saving
  127. schema: JSON schema for validation
  128. """
  129. self.config_path = Path(config_path)
  130. self.plugin_name = plugin_name
  131. self.auto_save = auto_save
  132. self.backup_enabled = backup_enabled
  133. self.schema = schema
  134. # Configuration storage
  135. self._config: Dict[str, Any] = {}
  136. self._config_values: Dict[str, ConfigValue] = {}
  137. self._config_lock = threading.RLock()
  138. # Change tracking
  139. self._change_callbacks: List[Callable[[str, Any, Any], None]] = []
  140. self._is_modified = False
  141. self._last_load_time: Optional[float] = None
  142. self._last_save_time: Optional[float] = None
  143. # Environment variable prefix
  144. self._env_prefix = f"TRIXY_{plugin_name.upper()}_"
  145. pprint(f"ConfigHandler created for plugin {plugin_name}")
  146. def load_config(self, reload: bool = False) -> Dict[str, Any]:
  147. """
  148. Load configuration from file with intelligent type detection.
  149. Args:
  150. reload: Force reload even if already loaded
  151. Returns:
  152. Dict containing the loaded configuration
  153. Raises:
  154. ConfigLoadError: If configuration cannot be loaded
  155. """
  156. if not reload and self._config and self._last_load_time:
  157. return self._config.copy()
  158. try:
  159. with self._config_lock:
  160. if self.config_path.exists():
  161. with open(self.config_path, 'r', encoding='utf-8') as f:
  162. raw_config = json.load(f)
  163. pprint(f"Loaded config file for plugin {self.plugin_name}")
  164. else:
  165. raw_config = {}
  166. pprint(f"No config file found for plugin {self.plugin_name}, using empty config")
  167. # Process configuration with type detection
  168. self._config = {}
  169. self._config_values = {}
  170. self._process_config_values(raw_config)
  171. # Apply environment variable overrides
  172. self._apply_env_overrides()
  173. # Validate configuration if schema provided
  174. if self.schema:
  175. self._validate_config()
  176. # Set defaults for missing values
  177. self._apply_defaults()
  178. self._last_load_time = time.time()
  179. self._is_modified = False
  180. pprint(f"Configuration loaded for plugin {self.plugin_name} with {len(self._config)} keys")
  181. return self._config.copy()
  182. except Exception as e:
  183. error_msg = f"Failed to load config for plugin {self.plugin_name}: {e}"
  184. pprint(error_msg)
  185. raise ConfigLoadError(error_msg) from e
  186. def save_config(self, backup: bool = None) -> None:
  187. """
  188. Save configuration to file with optional backup.
  189. Args:
  190. backup: Create backup before saving (uses instance default if None)
  191. Raises:
  192. ConfigSaveError: If configuration cannot be saved
  193. """
  194. if backup is None:
  195. backup = self.backup_enabled
  196. try:
  197. with self._config_lock:
  198. # Ensure directory exists
  199. self.config_path.parent.mkdir(parents=True, exist_ok=True)
  200. # Create backup if requested
  201. if backup and self.config_path.exists():
  202. backup_path = self.config_path.with_suffix('.json.bak')
  203. backup_path.write_text(self.config_path.read_text(encoding='utf-8'), encoding='utf-8')
  204. pprint(f"Created backup: {backup_path}")
  205. # Prepare config for saving (convert ConfigValues back to raw values)
  206. save_config = {}
  207. for key, value in self._config.items():
  208. save_config[key] = self._serialize_value(value)
  209. # Write configuration with pretty formatting
  210. with open(self.config_path, 'w', encoding='utf-8') as f:
  211. json.dump(save_config, f, indent=2, sort_keys=True, ensure_ascii=False)
  212. self._last_save_time = time.time()
  213. self._is_modified = False
  214. pprint(f"Configuration saved for plugin {self.plugin_name}")
  215. except Exception as e:
  216. error_msg = f"Failed to save config for plugin {self.plugin_name}: {e}"
  217. pprint(error_msg)
  218. raise ConfigSaveError(error_msg) from e
  219. def _process_config_values(self, raw_config: Dict[str, Any]) -> None:
  220. """Process raw configuration values with type detection."""
  221. for key, value in raw_config.items():
  222. config_value = self._create_config_value(key, value)
  223. self._config_values[key] = config_value
  224. self._config[key] = config_value.value
  225. def _create_config_value(self, key: str, value: Any) -> ConfigValue:
  226. """Create a ConfigValue with type detection."""
  227. original_type = self._detect_value_type(value)
  228. cast_value = self._cast_value(value, original_type)
  229. return ConfigValue(
  230. key=key,
  231. value=cast_value,
  232. original_type=original_type,
  233. last_modified=time.time()
  234. )
  235. def _detect_value_type(self, value: Any) -> ConfigValueType:
  236. """Detect the type of a configuration value."""
  237. if value is None:
  238. return ConfigValueType.NULL
  239. elif isinstance(value, bool):
  240. return ConfigValueType.BOOLEAN
  241. elif isinstance(value, int):
  242. return ConfigValueType.INTEGER
  243. elif isinstance(value, float):
  244. return ConfigValueType.FLOAT
  245. elif isinstance(value, str):
  246. # Try to detect if string represents another type
  247. if value.lower() in ('true', 'false', 'yes', 'no', '1', '0', 'on', 'off', 'enabled', 'disabled'):
  248. return ConfigValueType.BOOLEAN
  249. try:
  250. int(value)
  251. return ConfigValueType.INTEGER
  252. except ValueError:
  253. try:
  254. float(value)
  255. return ConfigValueType.FLOAT
  256. except ValueError:
  257. return ConfigValueType.STRING
  258. elif isinstance(value, list):
  259. return ConfigValueType.LIST
  260. elif isinstance(value, dict):
  261. return ConfigValueType.DICT
  262. else:
  263. return ConfigValueType.STRING
  264. def _cast_value(self, value: Any, target_type: ConfigValueType) -> Any:
  265. """Cast a value to the target type."""
  266. if value is None:
  267. return None
  268. try:
  269. if target_type == ConfigValueType.BOOLEAN and isinstance(value, str):
  270. return value.lower() in ('true', 'yes', '1', 'on', 'enabled')
  271. elif target_type == ConfigValueType.INTEGER and isinstance(value, str):
  272. return int(value)
  273. elif target_type == ConfigValueType.FLOAT and isinstance(value, str):
  274. return float(value)
  275. elif target_type == ConfigValueType.LIST and not isinstance(value, list):
  276. return [value] # Wrap single values in list
  277. else:
  278. return value
  279. except (ValueError, TypeError):
  280. pprint(f"Warning: Failed to cast value {value} to {target_type.value}, using original")
  281. return value
  282. def _apply_env_overrides(self) -> None:
  283. """Apply environment variable overrides to configuration."""
  284. for key in list(self._config.keys()):
  285. env_key = f"{self._env_prefix}{key.upper()}"
  286. env_value = os.getenv(env_key)
  287. if env_value is not None:
  288. # Detect type and cast environment value
  289. config_value = self._config_values[key]
  290. cast_value = self._cast_value(env_value, config_value.original_type)
  291. pprint(f"Environment override for {key}: {self._config[key]} -> {cast_value}")
  292. self._config[key] = cast_value
  293. config_value.value = cast_value
  294. config_value.last_modified = time.time()
  295. def _validate_config(self) -> None:
  296. """Validate configuration against schema."""
  297. if not self.schema or not HAS_JSONSCHEMA:
  298. if not HAS_JSONSCHEMA and self.schema:
  299. pprint("Warning: jsonschema not available, skipping validation")
  300. return
  301. try:
  302. validate(instance=self._config, schema=self.schema)
  303. except ValidationError as e:
  304. raise ConfigValidationError(f"Configuration validation failed: {e.message}")
  305. def _apply_defaults(self) -> None:
  306. """Apply default values for missing configuration keys."""
  307. if not self.schema or 'properties' not in self.schema:
  308. return
  309. for key, schema_def in self.schema['properties'].items():
  310. if key not in self._config and 'default' in schema_def:
  311. default_value = schema_def['default']
  312. config_value = self._create_config_value(key, default_value)
  313. config_value.default_value = default_value
  314. config_value.required = key in self.schema.get('required', [])
  315. config_value.description = schema_def.get('description', '')
  316. self._config_values[key] = config_value
  317. self._config[key] = config_value.value
  318. pprint(f"Applied default value for {key}: {default_value}")
  319. def _serialize_value(self, value: Any) -> Any:
  320. """Serialize a value for JSON storage."""
  321. # Handle special types that aren't JSON serializable
  322. if hasattr(value, 'to_dict'):
  323. return value.to_dict()
  324. elif hasattr(value, '__dict__'):
  325. return {k: v for k, v in value.__dict__.items() if not k.startswith('_')}
  326. else:
  327. return value
  328. # Public API Methods
  329. def get_value(self, key: str, default: Any = None, type_cast: Optional[Type] = None) -> Any:
  330. """
  331. Get a configuration value with optional type casting.
  332. Args:
  333. key: Configuration key
  334. default: Default value if key doesn't exist
  335. type_cast: Type to cast value to
  336. Returns:
  337. Configuration value
  338. """
  339. with self._config_lock:
  340. if key not in self._config:
  341. return default
  342. value = self._config[key]
  343. if type_cast is not None:
  344. try:
  345. if type_cast == bool and isinstance(value, str):
  346. return value.lower() in ('true', '1', 'yes', 'on', 'enabled')
  347. else:
  348. return type_cast(value)
  349. except (ValueError, TypeError):
  350. pprint(f"Warning: Failed to cast {key} to {type_cast.__name__}, using original value")
  351. return value
  352. def set_value(self, key: str, value: Any, notify: bool = True) -> None:
  353. """
  354. Set a configuration value.
  355. Args:
  356. key: Configuration key
  357. value: Value to set
  358. notify: Whether to notify change callbacks
  359. """
  360. with self._config_lock:
  361. old_value = self._config.get(key)
  362. # Create or update ConfigValue
  363. if key in self._config_values:
  364. config_value = self._config_values[key]
  365. config_value.value = value
  366. config_value.last_modified = time.time()
  367. else:
  368. config_value = self._create_config_value(key, value)
  369. self._config_values[key] = config_value
  370. self._config[key] = value
  371. self._is_modified = True
  372. # Auto-save if enabled
  373. if self.auto_save:
  374. self.save_config()
  375. # Notify callbacks
  376. if notify and old_value != value:
  377. self._notify_change(key, old_value, value)
  378. # Type-specific getters
  379. def get_string(self, key: str, default: str = "") -> str:
  380. """Get a string value."""
  381. return self.get_value(key, default, str)
  382. def get_int(self, key: str, default: int = 0) -> int:
  383. """Get an integer value."""
  384. return self.get_value(key, default, int)
  385. def get_float(self, key: str, default: float = 0.0) -> float:
  386. """Get a float value."""
  387. return self.get_value(key, default, float)
  388. def get_bool(self, key: str, default: bool = False) -> bool:
  389. """Get a boolean value."""
  390. return self.get_value(key, default, bool)
  391. def get_list(self, key: str, default: List = None) -> List:
  392. """Get a list value."""
  393. if default is None:
  394. default = []
  395. return self.get_value(key, default, list)
  396. def get_dict(self, key: str, default: Dict = None) -> Dict:
  397. """Get a dictionary value."""
  398. if default is None:
  399. default = {}
  400. return self.get_value(key, default, dict)
  401. # Change notifications
  402. def add_change_callback(self, callback: Callable[[str, Any, Any], None]) -> None:
  403. """Add a callback for configuration changes."""
  404. self._change_callbacks.append(callback)
  405. def remove_change_callback(self, callback: Callable[[str, Any, Any], None]) -> None:
  406. """Remove a change callback."""
  407. if callback in self._change_callbacks:
  408. self._change_callbacks.remove(callback)
  409. def _notify_change(self, key: str, old_value: Any, new_value: Any) -> None:
  410. """Notify all callbacks of a configuration change."""
  411. for callback in self._change_callbacks:
  412. try:
  413. callback(key, old_value, new_value)
  414. except Exception as e:
  415. pprint(f"Error in change callback for {key}: {e}")
  416. # Configuration management
  417. def has_key(self, key: str) -> bool:
  418. """Check if a configuration key exists."""
  419. with self._config_lock:
  420. return key in self._config
  421. def remove_key(self, key: str) -> bool:
  422. """
  423. Remove a configuration key.
  424. Args:
  425. key: Key to remove
  426. Returns:
  427. bool: True if key was removed, False if not found
  428. """
  429. with self._config_lock:
  430. if key in self._config:
  431. old_value = self._config[key]
  432. del self._config[key]
  433. if key in self._config_values:
  434. del self._config_values[key]
  435. self._is_modified = True
  436. if self.auto_save:
  437. self.save_config()
  438. self._notify_change(key, old_value, None)
  439. return True
  440. return False
  441. def get_all_keys(self) -> Set[str]:
  442. """Get all configuration keys."""
  443. with self._config_lock:
  444. return set(self._config.keys())
  445. def get_config_copy(self) -> Dict[str, Any]:
  446. """Get a copy of the entire configuration."""
  447. with self._config_lock:
  448. return copy.deepcopy(self._config)
  449. def clear_config(self) -> None:
  450. """Clear all configuration values."""
  451. with self._config_lock:
  452. old_config = self._config.copy()
  453. self._config.clear()
  454. self._config_values.clear()
  455. self._is_modified = True
  456. if self.auto_save:
  457. self.save_config()
  458. # Notify for each cleared key
  459. for key, old_value in old_config.items():
  460. self._notify_change(key, old_value, None)
  461. def is_modified(self) -> bool:
  462. """Check if configuration has been modified since last save."""
  463. return self._is_modified
  464. def get_config_info(self) -> Dict[str, Any]:
  465. """Get information about the configuration."""
  466. with self._config_lock:
  467. return {
  468. 'plugin_name': self.plugin_name,
  469. 'config_path': str(self.config_path),
  470. 'key_count': len(self._config),
  471. 'is_modified': self._is_modified,
  472. 'last_load_time': self._last_load_time,
  473. 'last_save_time': self._last_save_time,
  474. 'auto_save': self.auto_save,
  475. 'backup_enabled': self.backup_enabled,
  476. 'has_schema': self.schema is not None,
  477. 'config_values': {
  478. key: {
  479. 'type': cv.original_type.value,
  480. 'is_modified': cv.is_modified(),
  481. 'last_modified': cv.last_modified,
  482. 'required': cv.required,
  483. 'description': cv.description
  484. }
  485. for key, cv in self._config_values.items()
  486. }
  487. }
  488. # Utility methods
  489. def backup_config(self, backup_path: Optional[Path] = None) -> Path:
  490. """
  491. Create a backup of the current configuration.
  492. Args:
  493. backup_path: Optional custom backup path
  494. Returns:
  495. Path to the backup file
  496. """
  497. if backup_path is None:
  498. timestamp = int(time.time())
  499. backup_path = self.config_path.with_suffix(f'.{timestamp}.bak')
  500. if self.config_path.exists():
  501. backup_path.write_text(
  502. self.config_path.read_text(encoding='utf-8'),
  503. encoding='utf-8'
  504. )
  505. pprint(f"Configuration backed up to: {backup_path}")
  506. return backup_path
  507. def restore_config(self, backup_path: Path) -> None:
  508. """
  509. Restore configuration from a backup.
  510. Args:
  511. backup_path: Path to the backup file
  512. """
  513. if not backup_path.exists():
  514. raise ConfigLoadError(f"Backup file not found: {backup_path}")
  515. # Copy backup to current config
  516. self.config_path.write_text(
  517. backup_path.read_text(encoding='utf-8'),
  518. encoding='utf-8'
  519. )
  520. # Reload configuration
  521. self.load_config(reload=True)
  522. pprint(f"Configuration restored from: {backup_path}")
  523. def __repr__(self) -> str:
  524. """String representation of the config handler."""
  525. return f"<PluginConfigHandler plugin='{self.plugin_name}' keys={len(self._config)} modified={self._is_modified}>"