persistence.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889
  1. """
  2. Persistence System for Trixy Scheduler
  3. This module provides comprehensive persistence capabilities for the scheduler system,
  4. including schedule storage, loading, backup, and recovery functionality. Supports
  5. multiple storage formats and ensures data integrity.
  6. Key Features:
  7. - JSON-based schedule storage with human-readable format
  8. - Automatic backup and recovery mechanisms
  9. - Data validation and integrity checking
  10. - Atomic operations to prevent data corruption
  11. - Version management and migration support
  12. - Compression and encryption support (optional)
  13. - Thread-safe file operations
  14. Storage Format:
  15. - Primary format: JSON with complete schedule definitions
  16. - Backup format: Timestamped copies with rotation
  17. - Migration support: Version-aware loading and conversion
  18. - Validation: Schema validation and data integrity checks
  19. Usage:
  20. from trixy_core.scheduler.persistence import SchedulePersistence
  21. # Create persistence manager
  22. persistence = SchedulePersistence("/path/to/schedules.json")
  23. # Save schedules
  24. schedules = [schedule1, schedule2, schedule3]
  25. persistence.save_schedules(schedules)
  26. # Load schedules
  27. loaded_schedules = persistence.load_schedules()
  28. # Create backup
  29. backup_path = persistence.create_backup()
  30. # Restore from backup
  31. persistence.restore_from_backup(backup_path)
  32. """
  33. import os
  34. import json
  35. import time
  36. import shutil
  37. import gzip
  38. import threading
  39. import tempfile
  40. from datetime import datetime, timedelta
  41. from pathlib import Path
  42. from typing import List, Dict, Any, Optional, Union, Tuple
  43. from dataclasses import dataclass, field
  44. from enum import Enum
  45. import hashlib
  46. import uuid
  47. def pprint(message: str) -> None:
  48. """
  49. Persistence logging function that adapts based on mode.
  50. Uses the same pattern as specified in CLAUDE.md.
  51. """
  52. print(f"[SCHEDULER.PERSISTENCE] {message}")
  53. class PersistenceError(Exception):
  54. """Base exception for persistence-related errors."""
  55. pass
  56. class ValidationError(PersistenceError):
  57. """Raised when data validation fails."""
  58. pass
  59. class CorruptionError(PersistenceError):
  60. """Raised when data corruption is detected."""
  61. pass
  62. class StorageFormat(Enum):
  63. """Supported storage formats."""
  64. JSON = "json"
  65. JSON_COMPRESSED = "json.gz"
  66. class BackupPolicy(Enum):
  67. """Backup retention policies."""
  68. NONE = "none"
  69. DAILY = "daily"
  70. WEEKLY = "weekly"
  71. MONTHLY = "monthly"
  72. CUSTOM = "custom"
  73. @dataclass
  74. class PersistenceConfig:
  75. """Configuration for persistence system."""
  76. storage_path: str
  77. backup_directory: Optional[str] = None
  78. backup_policy: BackupPolicy = BackupPolicy.DAILY
  79. max_backups: int = 30
  80. compress_backups: bool = True
  81. validate_on_load: bool = True
  82. atomic_writes: bool = True
  83. file_permissions: int = 0o644
  84. create_directories: bool = True
  85. def __post_init__(self):
  86. """Post-initialization validation."""
  87. if self.max_backups < 0:
  88. raise ValueError("Max backups cannot be negative")
  89. if not self.storage_path:
  90. raise ValueError("Storage path is required")
  91. @dataclass
  92. class BackupInfo:
  93. """Information about a backup file."""
  94. path: str
  95. timestamp: float
  96. size_bytes: int
  97. checksum: str
  98. schedule_count: int
  99. version: str = "1.0.0"
  100. @property
  101. def age_hours(self) -> float:
  102. """Get backup age in hours."""
  103. return (time.time() - self.timestamp) / 3600
  104. @property
  105. def creation_date(self) -> datetime:
  106. """Get backup creation date."""
  107. return datetime.fromtimestamp(self.timestamp)
  108. def to_dict(self) -> Dict[str, Any]:
  109. """Convert to dictionary."""
  110. return {
  111. 'path': self.path,
  112. 'timestamp': self.timestamp,
  113. 'size_bytes': self.size_bytes,
  114. 'checksum': self.checksum,
  115. 'schedule_count': self.schedule_count,
  116. 'version': self.version,
  117. 'creation_date': self.creation_date.isoformat(),
  118. 'age_hours': self.age_hours
  119. }
  120. class ScheduleStorage:
  121. """
  122. Low-level storage operations for schedule data.
  123. Handles the actual file I/O operations with integrity checking
  124. and atomic operations.
  125. """
  126. def __init__(self, config: PersistenceConfig):
  127. """
  128. Initialize storage.
  129. Args:
  130. config: Persistence configuration
  131. """
  132. self.config = config
  133. self._lock = threading.RLock()
  134. # Ensure storage directory exists
  135. if config.create_directories:
  136. storage_dir = os.path.dirname(config.storage_path)
  137. if storage_dir:
  138. os.makedirs(storage_dir, exist_ok=True)
  139. if config.backup_directory:
  140. os.makedirs(config.backup_directory, exist_ok=True)
  141. def read_data(self, file_path: str) -> Dict[str, Any]:
  142. """
  143. Read data from file with integrity checking.
  144. Args:
  145. file_path: Path to file to read
  146. Returns:
  147. Dict[str, Any]: Loaded data
  148. Raises:
  149. PersistenceError: If file cannot be read or is corrupted
  150. """
  151. if not os.path.exists(file_path):
  152. raise PersistenceError(f"File does not exist: {file_path}")
  153. try:
  154. # Determine format from extension
  155. if file_path.endswith('.gz'):
  156. with gzip.open(file_path, 'rt', encoding='utf-8') as f:
  157. data = json.load(f)
  158. else:
  159. with open(file_path, 'r', encoding='utf-8') as f:
  160. data = json.load(f)
  161. # Validate data structure
  162. if self.config.validate_on_load:
  163. self._validate_data_structure(data)
  164. return data
  165. except json.JSONDecodeError as e:
  166. raise CorruptionError(f"Invalid JSON in file {file_path}: {e}")
  167. except Exception as e:
  168. raise PersistenceError(f"Failed to read file {file_path}: {e}")
  169. def write_data(self, file_path: str, data: Dict[str, Any], compressed: bool = False) -> None:
  170. """
  171. Write data to file with atomic operations.
  172. Args:
  173. file_path: Path to file to write
  174. data: Data to write
  175. compressed: Whether to compress the file
  176. Raises:
  177. PersistenceError: If file cannot be written
  178. """
  179. with self._lock:
  180. # Validate data before writing
  181. self._validate_data_structure(data)
  182. if self.config.atomic_writes:
  183. self._write_atomic(file_path, data, compressed)
  184. else:
  185. self._write_direct(file_path, data, compressed)
  186. def _write_atomic(self, file_path: str, data: Dict[str, Any], compressed: bool) -> None:
  187. """Write data atomically using temporary file."""
  188. temp_path = f"{file_path}.tmp.{uuid.uuid4().hex[:8]}"
  189. try:
  190. # Write to temporary file
  191. self._write_direct(temp_path, data, compressed)
  192. # Atomic move to final location
  193. shutil.move(temp_path, file_path)
  194. # Set file permissions
  195. os.chmod(file_path, self.config.file_permissions)
  196. except Exception as e:
  197. # Clean up temporary file on error
  198. if os.path.exists(temp_path):
  199. try:
  200. os.remove(temp_path)
  201. except:
  202. pass
  203. raise PersistenceError(f"Failed to write file {file_path}: {e}")
  204. def _write_direct(self, file_path: str, data: Dict[str, Any], compressed: bool) -> None:
  205. """Write data directly to file."""
  206. try:
  207. if compressed:
  208. with gzip.open(file_path, 'wt', encoding='utf-8') as f:
  209. json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True)
  210. else:
  211. with open(file_path, 'w', encoding='utf-8') as f:
  212. json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True)
  213. except Exception as e:
  214. raise PersistenceError(f"Failed to write data to {file_path}: {e}")
  215. def _validate_data_structure(self, data: Dict[str, Any]) -> None:
  216. """
  217. Validate data structure.
  218. Args:
  219. data: Data to validate
  220. Raises:
  221. ValidationError: If data structure is invalid
  222. """
  223. required_fields = ['version', 'timestamp', 'schedules']
  224. for field in required_fields:
  225. if field not in data:
  226. raise ValidationError(f"Missing required field: {field}")
  227. if not isinstance(data['schedules'], list):
  228. raise ValidationError("Schedules field must be a list")
  229. # Validate version format
  230. version = data['version']
  231. if not isinstance(version, str) or not version.count('.') >= 1:
  232. raise ValidationError(f"Invalid version format: {version}")
  233. def calculate_checksum(self, file_path: str) -> str:
  234. """
  235. Calculate file checksum.
  236. Args:
  237. file_path: Path to file
  238. Returns:
  239. str: MD5 checksum
  240. """
  241. hash_md5 = hashlib.md5()
  242. try:
  243. with open(file_path, 'rb') as f:
  244. for chunk in iter(lambda: f.read(4096), b""):
  245. hash_md5.update(chunk)
  246. return hash_md5.hexdigest()
  247. except Exception as e:
  248. raise PersistenceError(f"Failed to calculate checksum for {file_path}: {e}")
  249. def get_file_info(self, file_path: str) -> Dict[str, Any]:
  250. """
  251. Get file information.
  252. Args:
  253. file_path: Path to file
  254. Returns:
  255. Dict[str, Any]: File information
  256. """
  257. try:
  258. stat = os.stat(file_path)
  259. return {
  260. 'path': file_path,
  261. 'size_bytes': stat.st_size,
  262. 'modified_time': stat.st_mtime,
  263. 'created_time': stat.st_ctime,
  264. 'permissions': oct(stat.st_mode)[-3:],
  265. 'exists': True
  266. }
  267. except FileNotFoundError:
  268. return {
  269. 'path': file_path,
  270. 'exists': False
  271. }
  272. except Exception as e:
  273. raise PersistenceError(f"Failed to get file info for {file_path}: {e}")
  274. class SchedulePersistence:
  275. """
  276. High-level persistence manager for schedule data.
  277. Provides methods for saving, loading, backup, and recovery
  278. of schedule configurations.
  279. """
  280. def __init__(
  281. self,
  282. storage_path: str,
  283. backup_directory: Optional[str] = None,
  284. config: Optional[PersistenceConfig] = None
  285. ):
  286. """
  287. Initialize persistence manager.
  288. Args:
  289. storage_path: Path to main storage file
  290. backup_directory: Directory for backups (optional)
  291. config: Persistence configuration (optional)
  292. """
  293. if config is None:
  294. config = PersistenceConfig(
  295. storage_path=storage_path,
  296. backup_directory=backup_directory
  297. )
  298. self.config = config
  299. self.storage = ScheduleStorage(config)
  300. self._lock = threading.RLock()
  301. # Default backup directory
  302. if not self.config.backup_directory:
  303. self.config.backup_directory = os.path.join(
  304. os.path.dirname(storage_path), 'backups'
  305. )
  306. pprint(f"SchedulePersistence initialized: {storage_path}")
  307. def save_schedules(self, schedules: List['ScheduleEntry']) -> None:
  308. """
  309. Save schedules to storage.
  310. Args:
  311. schedules: List of schedule entries to save
  312. Raises:
  313. PersistenceError: If save operation fails
  314. """
  315. with self._lock:
  316. pprint(f"Saving {len(schedules)} schedules to {self.config.storage_path}")
  317. # Create backup before saving if file exists
  318. if os.path.exists(self.config.storage_path):
  319. try:
  320. self._create_backup_if_needed()
  321. except Exception as e:
  322. pprint(f"Warning: Failed to create backup before save: {e}")
  323. # Convert schedules to dictionary format
  324. data = self._schedules_to_dict(schedules)
  325. # Write to storage
  326. compressed = self.config.storage_path.endswith('.gz')
  327. self.storage.write_data(self.config.storage_path, data, compressed)
  328. pprint(f"Successfully saved {len(schedules)} schedules")
  329. def load_schedules(self) -> List['ScheduleEntry']:
  330. """
  331. Load schedules from storage.
  332. Returns:
  333. List[ScheduleEntry]: List of loaded schedule entries
  334. Raises:
  335. PersistenceError: If load operation fails
  336. """
  337. if not os.path.exists(self.config.storage_path):
  338. pprint(f"Storage file does not exist: {self.config.storage_path}")
  339. return []
  340. with self._lock:
  341. pprint(f"Loading schedules from {self.config.storage_path}")
  342. try:
  343. data = self.storage.read_data(self.config.storage_path)
  344. schedules = self._dict_to_schedules(data)
  345. pprint(f"Successfully loaded {len(schedules)} schedules")
  346. return schedules
  347. except Exception as e:
  348. pprint(f"Failed to load schedules: {e}")
  349. # Try to recover from backup
  350. return self._recover_from_backup()
  351. def _schedules_to_dict(self, schedules: List['ScheduleEntry']) -> Dict[str, Any]:
  352. """
  353. Convert schedules to dictionary format.
  354. Args:
  355. schedules: List of schedule entries
  356. Returns:
  357. Dict[str, Any]: Dictionary representation
  358. """
  359. return {
  360. 'version': '1.0.0',
  361. 'timestamp': time.time(),
  362. 'created_date': datetime.now().isoformat(),
  363. 'schedule_count': len(schedules),
  364. 'schedules': [schedule.to_dict() for schedule in schedules],
  365. 'metadata': {
  366. 'created_by': 'Trixy Scheduler System',
  367. 'format': 'json',
  368. 'encoding': 'utf-8'
  369. }
  370. }
  371. def _dict_to_schedules(self, data: Dict[str, Any]) -> List['ScheduleEntry']:
  372. """
  373. Convert dictionary format to schedules.
  374. Args:
  375. data: Dictionary data
  376. Returns:
  377. List[ScheduleEntry]: List of schedule entries
  378. """
  379. # Import here to avoid circular imports
  380. from .schedule_entry import ScheduleEntry
  381. from .triggers import TriggerFactory
  382. from .actions import ActionFactory
  383. schedules = []
  384. for schedule_data in data.get('schedules', []):
  385. try:
  386. # Create schedule entry
  387. schedule = ScheduleEntry(
  388. name=schedule_data['name'],
  389. description=schedule_data.get('description', ''),
  390. enabled=schedule_data.get('enabled', True)
  391. )
  392. # Restore triggers
  393. for trigger_data in schedule_data.get('triggers', []):
  394. trigger = TriggerFactory.from_dict(trigger_data)
  395. schedule.add_trigger(trigger)
  396. # Restore actions
  397. for action_data in schedule_data.get('actions', []):
  398. action = ActionFactory.from_dict(action_data)
  399. schedule.add_action(action)
  400. # Restore metadata
  401. schedule.created_at = schedule_data.get('created_at', schedule.created_at)
  402. schedule.last_modified = schedule_data.get('last_modified', schedule.last_modified)
  403. schedule.last_executed = schedule_data.get('last_executed')
  404. schedule.execution_count = schedule_data.get('execution_count', 0)
  405. schedule.error_count = schedule_data.get('error_count', 0)
  406. schedules.append(schedule)
  407. except Exception as e:
  408. pprint(f"Failed to load schedule '{schedule_data.get('name', 'unknown')}': {e}")
  409. # Continue loading other schedules
  410. return schedules
  411. def create_backup(self, backup_name: Optional[str] = None) -> str:
  412. """
  413. Create a backup of the current schedules.
  414. Args:
  415. backup_name: Optional custom backup name
  416. Returns:
  417. str: Path to created backup
  418. Raises:
  419. PersistenceError: If backup creation fails
  420. """
  421. if not os.path.exists(self.config.storage_path):
  422. raise PersistenceError("Cannot create backup: storage file does not exist")
  423. with self._lock:
  424. # Generate backup filename
  425. if backup_name is None:
  426. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  427. backup_name = f"schedules_backup_{timestamp}"
  428. # Add appropriate extension
  429. if self.config.compress_backups:
  430. backup_path = os.path.join(self.config.backup_directory, f"{backup_name}.json.gz")
  431. else:
  432. backup_path = os.path.join(self.config.backup_directory, f"{backup_name}.json")
  433. # Ensure backup directory exists
  434. os.makedirs(os.path.dirname(backup_path), exist_ok=True)
  435. try:
  436. # Read current data
  437. data = self.storage.read_data(self.config.storage_path)
  438. # Add backup metadata
  439. data['backup_info'] = {
  440. 'original_path': self.config.storage_path,
  441. 'backup_timestamp': time.time(),
  442. 'backup_date': datetime.now().isoformat()
  443. }
  444. # Write backup
  445. self.storage.write_data(backup_path, data, self.config.compress_backups)
  446. pprint(f"Created backup: {backup_path}")
  447. # Clean up old backups
  448. self._cleanup_old_backups()
  449. return backup_path
  450. except Exception as e:
  451. raise PersistenceError(f"Failed to create backup: {e}")
  452. def restore_from_backup(self, backup_path: str) -> None:
  453. """
  454. Restore schedules from a backup.
  455. Args:
  456. backup_path: Path to backup file
  457. Raises:
  458. PersistenceError: If restore operation fails
  459. """
  460. if not os.path.exists(backup_path):
  461. raise PersistenceError(f"Backup file does not exist: {backup_path}")
  462. with self._lock:
  463. pprint(f"Restoring schedules from backup: {backup_path}")
  464. try:
  465. # Create backup of current state first
  466. if os.path.exists(self.config.storage_path):
  467. emergency_backup = self.create_backup("emergency_pre_restore")
  468. pprint(f"Created emergency backup: {emergency_backup}")
  469. # Read backup data
  470. backup_data = self.storage.read_data(backup_path)
  471. # Remove backup metadata before restoring
  472. if 'backup_info' in backup_data:
  473. del backup_data['backup_info']
  474. # Write to main storage
  475. compressed = self.config.storage_path.endswith('.gz')
  476. self.storage.write_data(self.config.storage_path, backup_data, compressed)
  477. pprint(f"Successfully restored schedules from backup")
  478. except Exception as e:
  479. raise PersistenceError(f"Failed to restore from backup: {e}")
  480. def _recover_from_backup(self) -> List['ScheduleEntry']:
  481. """
  482. Attempt to recover schedules from most recent backup.
  483. Returns:
  484. List[ScheduleEntry]: Recovered schedules or empty list
  485. """
  486. pprint("Attempting to recover from backup...")
  487. backups = self.list_backups()
  488. if not backups:
  489. pprint("No backups available for recovery")
  490. return []
  491. # Try most recent backup first
  492. for backup_info in backups:
  493. try:
  494. pprint(f"Trying to recover from backup: {backup_info.path}")
  495. data = self.storage.read_data(backup_info.path)
  496. schedules = self._dict_to_schedules(data)
  497. pprint(f"Successfully recovered {len(schedules)} schedules from backup")
  498. return schedules
  499. except Exception as e:
  500. pprint(f"Failed to recover from backup {backup_info.path}: {e}")
  501. continue
  502. pprint("All backup recovery attempts failed")
  503. return []
  504. def _create_backup_if_needed(self) -> None:
  505. """Create backup if needed based on backup policy."""
  506. if self.config.backup_policy == BackupPolicy.NONE:
  507. return
  508. # Check if backup is needed
  509. backups = self.list_backups()
  510. if not backups:
  511. # No backups exist, create one
  512. self.create_backup()
  513. return
  514. # Check if we need a new backup based on policy
  515. latest_backup = backups[0] # list_backups returns sorted by timestamp desc
  516. hours_since_backup = latest_backup.age_hours
  517. backup_needed = False
  518. if self.config.backup_policy == BackupPolicy.DAILY and hours_since_backup >= 24:
  519. backup_needed = True
  520. elif self.config.backup_policy == BackupPolicy.WEEKLY and hours_since_backup >= 168: # 7 days
  521. backup_needed = True
  522. elif self.config.backup_policy == BackupPolicy.MONTHLY and hours_since_backup >= 720: # 30 days
  523. backup_needed = True
  524. if backup_needed:
  525. self.create_backup()
  526. def _cleanup_old_backups(self) -> None:
  527. """Clean up old backups based on retention policy."""
  528. if self.config.max_backups <= 0:
  529. return
  530. backups = self.list_backups()
  531. if len(backups) > self.config.max_backups:
  532. # Remove oldest backups
  533. backups_to_remove = backups[self.config.max_backups:]
  534. for backup_info in backups_to_remove:
  535. try:
  536. os.remove(backup_info.path)
  537. pprint(f"Removed old backup: {backup_info.path}")
  538. except Exception as e:
  539. pprint(f"Failed to remove old backup {backup_info.path}: {e}")
  540. def list_backups(self) -> List[BackupInfo]:
  541. """
  542. List available backups.
  543. Returns:
  544. List[BackupInfo]: List of backup information, sorted by timestamp (newest first)
  545. """
  546. if not os.path.exists(self.config.backup_directory):
  547. return []
  548. backups = []
  549. try:
  550. for filename in os.listdir(self.config.backup_directory):
  551. if not (filename.endswith('.json') or filename.endswith('.json.gz')):
  552. continue
  553. backup_path = os.path.join(self.config.backup_directory, filename)
  554. try:
  555. # Get file info
  556. file_info = self.storage.get_file_info(backup_path)
  557. if not file_info.get('exists', False):
  558. continue
  559. # Try to get schedule count from file
  560. schedule_count = 0
  561. try:
  562. data = self.storage.read_data(backup_path)
  563. schedule_count = data.get('schedule_count', len(data.get('schedules', [])))
  564. except:
  565. pass # Count will remain 0
  566. # Calculate checksum
  567. checksum = self.storage.calculate_checksum(backup_path)
  568. backup_info = BackupInfo(
  569. path=backup_path,
  570. timestamp=file_info['modified_time'],
  571. size_bytes=file_info['size_bytes'],
  572. checksum=checksum,
  573. schedule_count=schedule_count
  574. )
  575. backups.append(backup_info)
  576. except Exception as e:
  577. pprint(f"Failed to get backup info for {backup_path}: {e}")
  578. except Exception as e:
  579. pprint(f"Failed to list backups: {e}")
  580. # Sort by timestamp (newest first)
  581. backups.sort(key=lambda b: b.timestamp, reverse=True)
  582. return backups
  583. def get_storage_info(self) -> Dict[str, Any]:
  584. """
  585. Get information about storage.
  586. Returns:
  587. Dict[str, Any]: Storage information
  588. """
  589. info = {
  590. 'storage_path': self.config.storage_path,
  591. 'backup_directory': self.config.backup_directory,
  592. 'backup_policy': self.config.backup_policy.value,
  593. 'max_backups': self.config.max_backups,
  594. 'storage_exists': os.path.exists(self.config.storage_path),
  595. 'backup_count': len(self.list_backups())
  596. }
  597. # Add file info if storage exists
  598. if info['storage_exists']:
  599. file_info = self.storage.get_file_info(self.config.storage_path)
  600. info.update(file_info)
  601. # Add schedule count
  602. try:
  603. data = self.storage.read_data(self.config.storage_path)
  604. info['schedule_count'] = data.get('schedule_count', len(data.get('schedules', [])))
  605. except:
  606. info['schedule_count'] = 0
  607. return info
  608. def validate_storage(self) -> List[str]:
  609. """
  610. Validate storage integrity.
  611. Returns:
  612. List[str]: List of validation errors (empty if valid)
  613. """
  614. errors = []
  615. # Check if storage file exists
  616. if not os.path.exists(self.config.storage_path):
  617. errors.append(f"Storage file does not exist: {self.config.storage_path}")
  618. return errors
  619. try:
  620. # Try to load and validate data
  621. data = self.storage.read_data(self.config.storage_path)
  622. self.storage._validate_data_structure(data)
  623. # Try to convert to schedules (validates structure)
  624. schedules = self._dict_to_schedules(data)
  625. # Validate each schedule
  626. for i, schedule in enumerate(schedules):
  627. schedule_errors = schedule.validate()
  628. for error in schedule_errors:
  629. errors.append(f"Schedule {i} ({schedule.name}): {error}")
  630. except Exception as e:
  631. errors.append(f"Storage validation failed: {e}")
  632. return errors
  633. # Convenience functions
  634. def load_schedules_from_file(file_path: str) -> List['ScheduleEntry']:
  635. """
  636. Load schedules from a file.
  637. Args:
  638. file_path: Path to schedule file
  639. Returns:
  640. List[ScheduleEntry]: List of loaded schedules
  641. """
  642. persistence = SchedulePersistence(file_path)
  643. return persistence.load_schedules()
  644. def save_schedules_to_file(schedules: List['ScheduleEntry'], file_path: str) -> None:
  645. """
  646. Save schedules to a file.
  647. Args:
  648. schedules: List of schedules to save
  649. file_path: Path to save file
  650. """
  651. persistence = SchedulePersistence(file_path)
  652. persistence.save_schedules(schedules)
  653. def create_backup(
  654. source_file: str,
  655. backup_directory: Optional[str] = None,
  656. backup_name: Optional[str] = None
  657. ) -> str:
  658. """
  659. Create a backup of a schedule file.
  660. Args:
  661. source_file: Source schedule file
  662. backup_directory: Directory for backup (optional)
  663. backup_name: Custom backup name (optional)
  664. Returns:
  665. str: Path to created backup
  666. """
  667. persistence = SchedulePersistence(source_file, backup_directory)
  668. return persistence.create_backup(backup_name)
  669. def restore_from_backup(target_file: str, backup_file: str) -> None:
  670. """
  671. Restore schedules from a backup file.
  672. Args:
  673. target_file: Target schedule file
  674. backup_file: Backup file to restore from
  675. """
  676. persistence = SchedulePersistence(target_file)
  677. persistence.restore_from_backup(backup_file)
  678. # Module exports
  679. __all__ = [
  680. 'SchedulePersistence',
  681. 'ScheduleStorage',
  682. 'PersistenceError',
  683. 'ValidationError',
  684. 'CorruptionError',
  685. 'PersistenceConfig',
  686. 'BackupInfo',
  687. 'StorageFormat',
  688. 'BackupPolicy',
  689. 'load_schedules_from_file',
  690. 'save_schedules_to_file',
  691. 'create_backup',
  692. 'restore_from_backup',
  693. 'pprint'
  694. ]