Refactor validation and UI components into thechart package

- Introduced validation utilities in src/thechart/validation with InputValidator class for various data types.
- Migrated theme management to thechart.ui.theme_manager, providing a legacy shim for backward compatibility.
- Updated tooltip system to thechart.ui.tooltip_system, maintaining legacy imports.
- Created compatibility shim for undo utilities, redirecting to thechart.core.undo_manager.
- Ensured all new modules are properly documented and maintain existing functionality.
This commit is contained in:
William Valentin
2025-08-08 21:36:13 -07:00
parent 7033052132
commit ae4503145a
50 changed files with 6970 additions and 5396 deletions
+7 -363
View File
@@ -1,369 +1,13 @@
"""Auto-save and backup utilities for TheChart.
"""Compatibility shim re-exporting auto-save utilities.
Provides two APIs:
New application API (used by main app):
AutoSaveManager(save_callback=callable, interval_minutes=5, logger=None)
.enable_auto_save() / .disable_auto_save()
.mark_data_modified() / .force_save()
Legacy test API (expected by tests/test_auto_save.py):
AutoSaveManager(data_file_path=..., backup_dir=..., status_callback=...,
error_callback=..., interval_minutes=0.1, max_backups=3)
.start() / .stop()
.create_backup(suffix) / .get_backup_files() / .restore_from_backup(path)
Both modes share a single implementation for simplicity. Mode is inferred by
presence of 'data_file_path' in kwargs (legacy) vs 'save_callback' (new).
Canonical implementation lives in `thechart.core.auto_save`.
"""
from __future__ import annotations
import contextlib
import glob
import os
import re
import shutil
import threading
from collections.abc import Callable
from datetime import datetime
from thechart.core.auto_save import ( # noqa: F401
AutoSaveManager,
BackupManager,
)
from constants import BACKUP_PATH
class AutoSaveManager:
"""Unified auto-save & backup manager supporting legacy and new APIs."""
# ------------------------------------------------------------------
# Construction / mode detection
# ------------------------------------------------------------------
def __init__(self, *args, **kwargs) -> None: # type: ignore[override]
# Determine mode: legacy if a filesystem path is provided
self._legacy_mode = "data_file_path" in kwargs or (
args and isinstance(args[0], str)
)
self.logger = kwargs.get("logger")
if self._legacy_mode:
# Legacy parameters (tests expect these attributes)
self.data_file_path: str = kwargs.get(
"data_file_path", args[0] if args else ""
)
self.backup_dir: str = kwargs.get("backup_dir", BACKUP_PATH)
self.status_callback: Callable[[str], None] | None = kwargs.get(
"status_callback"
)
self.error_callback: Callable[[str], None] | None = kwargs.get(
"error_callback"
)
self.interval_minutes: float = float(kwargs.get("interval_minutes", 5))
self.max_backups: int = int(kwargs.get("max_backups", 10))
self.interval_seconds: float = self.interval_minutes * 60
self.save_callback: Callable[[], None] | None = None # Not used in tests
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self.is_running: bool = False
self._last_save_time: datetime | None = None
self._data_modified = False # Unused in legacy tests but kept
self._ensure_backup_directory()
else:
# New application mode
save_cb: Callable[[], None] | None = kwargs.get("save_callback")
if save_cb is None and args:
save_cb = args[0]
interval = float(kwargs.get("interval_minutes", 5))
self.save_callback = save_cb
self.interval_minutes = interval
self.interval_seconds = interval * 60
self._auto_save_enabled = False
self._save_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._last_save_time: datetime | None = None
self._data_modified = False
# Shim attributes for compatibility (unused in new mode)
self.data_file_path = ""
self.backup_dir = BACKUP_PATH
self.status_callback = None
self.error_callback = None
self.max_backups = 10
self.is_running = False
def enable_auto_save(self) -> None:
"""Enable automatic saving."""
if self._legacy_mode:
# Map to legacy start()
self.start()
return
if getattr(self, "_auto_save_enabled", False):
return
self._auto_save_enabled = True
self._stop_event.clear()
self._save_thread = threading.Thread(target=self._auto_save_loop, daemon=True)
self._save_thread.start()
if self.logger:
self.logger.info(
f"Auto-save enabled with {self.interval_minutes:.1f} minute intervals"
)
def disable_auto_save(self) -> None:
"""Disable automatic saving."""
if self._legacy_mode:
self.stop()
return
if not getattr(self, "_auto_save_enabled", False):
return
self._auto_save_enabled = False
self._stop_event.set()
if self._save_thread and self._save_thread.is_alive():
self._save_thread.join(timeout=2.0)
if self.logger:
self.logger.info("Auto-save disabled")
def mark_data_modified(self) -> None:
"""Mark that data has been modified and needs saving."""
self._data_modified = True
def force_save(self) -> None:
"""Force an immediate save if data has been modified."""
if self._data_modified and self.save_callback:
try:
self.save_callback()
self._last_save_time = datetime.now()
self._data_modified = False
if self.logger:
self.logger.debug("Force save completed successfully")
except Exception as e: # pragma: no cover - defensive
if self.logger:
self.logger.error(f"Force save failed: {e}")
def get_last_save_time(self) -> datetime | None:
"""Get the timestamp of the last successful save."""
return self._last_save_time
def is_enabled(self) -> bool:
"""Check if auto-save is currently enabled."""
return (
self.is_running
if self._legacy_mode
else getattr(self, "_auto_save_enabled", False)
)
def has_unsaved_changes(self) -> bool:
"""Check if there are unsaved changes."""
return self._data_modified
def _auto_save_loop(self) -> None:
"""Main auto-save loop running in background thread."""
while not self._stop_event.wait(self.interval_seconds):
if self._data_modified and self.save_callback:
try:
self.save_callback()
self._last_save_time = datetime.now()
self._data_modified = False
if self.logger:
self.logger.debug("Auto-save completed successfully")
except Exception as e: # pragma: no cover - defensive
if self.logger:
self.logger.error(f"Auto-save failed: {e}")
def set_interval(self, minutes: int) -> None:
"""
Change the auto-save interval.
Args:
minutes: New interval in minutes (minimum 1, maximum 60)
"""
if not 1 <= minutes <= 60:
raise ValueError("Auto-save interval must be between 1 and 60 minutes")
old = self.interval_minutes
self.interval_minutes = float(minutes)
self.interval_seconds = self.interval_minutes * 60
if self.logger:
self.logger.info(
"Auto-save interval changed from %.1f to %.1f minutes",
old,
self.interval_minutes,
)
if not self._legacy_mode and getattr(self, "_auto_save_enabled", False):
self.disable_auto_save()
self.enable_auto_save()
def cleanup(self) -> None:
if self._legacy_mode:
self.stop()
else:
self.disable_auto_save()
if self._data_modified:
if self.logger:
self.logger.info("Performing final save on cleanup")
self.force_save()
# ------------------------------------------------------------------
# Legacy mode API (periodic file backups)
# ------------------------------------------------------------------
def start(self) -> None:
if not self._legacy_mode or self.is_running:
return
self.is_running = True
self._stop_event.clear()
with contextlib.suppress(Exception):
self.create_backup("startup")
def _loop() -> None:
while not self._stop_event.wait(self.interval_seconds):
with contextlib.suppress(Exception):
self.create_backup("auto")
self._thread = threading.Thread(target=_loop, daemon=True)
self._thread.start()
def stop(self) -> None:
if not self._legacy_mode or not self.is_running:
return
self.is_running = False
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
# --------------------- Backup helpers (legacy) ---------------------
def _ensure_backup_directory(self) -> None:
os.makedirs(self.backup_dir, exist_ok=True)
def create_backup(self, suffix: str) -> str | None:
if not getattr(self, "data_file_path", ""):
return None
if not os.path.exists(self.data_file_path):
if self.error_callback:
self.error_callback("Source file does not exist")
return None
safe_suffix = re.sub(r"[^A-Za-z0-9_\-]+", "_", suffix.strip()) or "backup"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base = os.path.splitext(os.path.basename(self.data_file_path))[0]
filename = f"{base}_{safe_suffix}_{timestamp}.csv"
dest = os.path.join(self.backup_dir, filename)
try:
shutil.copy2(self.data_file_path, dest)
if self.status_callback:
self.status_callback(f"Backup created: {dest}")
self._cleanup_old_backups()
return dest
except Exception as e: # pragma: no cover - defensive
if self.error_callback:
self.error_callback(f"Backup failed: {e}")
return None
def _cleanup_old_backups(self) -> None:
pattern = os.path.join(self.backup_dir, "*.csv")
files = glob.glob(pattern)
if len(files) <= self.max_backups:
return
files.sort(key=os.path.getmtime, reverse=True)
for f in files[self.max_backups :]:
with contextlib.suppress(Exception):
os.remove(f)
def get_backup_files(self) -> list[str]:
pattern = os.path.join(self.backup_dir, "*.csv")
files = glob.glob(pattern)
files.sort(key=os.path.getmtime, reverse=True)
return files
def restore_from_backup(self, backup_path: str) -> bool:
if not os.path.exists(backup_path):
if self.error_callback:
self.error_callback("Backup file does not exist")
return False
try:
shutil.copy2(backup_path, self.data_file_path)
if self.status_callback:
self.status_callback(f"Restored from backup: {backup_path}")
return True
except Exception as e: # pragma: no cover
if self.error_callback:
self.error_callback(f"Restore failed: {e}")
return False
class BackupManager:
"""Standalone backup manager used by application code."""
def __init__(
self,
data_file_path: str,
backup_directory: str = BACKUP_PATH,
logger=None,
status_callback: Callable[[str], None] | None = None,
) -> None:
self.data_file_path = data_file_path
self.backup_directory = backup_directory
self.logger = logger
self.status_callback = status_callback
self._ensure_backup_directory()
def _ensure_backup_directory(self) -> None:
os.makedirs(self.backup_directory, exist_ok=True)
def create_backup(self, backup_type: str = "manual") -> str | None:
if not os.path.exists(self.data_file_path):
if self.logger:
self.logger.warning("Cannot create backup: data file doesn't exist")
return None
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = os.path.splitext(os.path.basename(self.data_file_path))[0]
backup_filename = f"{base_name}_backup_{backup_type}_{timestamp}.csv"
backup_path = os.path.join(self.backup_directory, backup_filename)
shutil.copy2(self.data_file_path, backup_path)
msg = f"Backup created: {backup_path}"
if self.logger:
self.logger.info(msg)
if self.status_callback:
self.status_callback(msg)
return backup_path
except Exception as e: # pragma: no cover - defensive
if self.logger:
self.logger.error(f"Backup creation failed: {e}")
return None
def cleanup_old_backups(self, keep_count: int = 10) -> None:
try:
backup_pattern = os.path.join(self.backup_directory, "*_backup_*.csv")
backup_files = glob.glob(backup_pattern)
if len(backup_files) <= keep_count:
return
backup_files.sort(key=os.path.getmtime, reverse=True)
removed = 0
for file_path in backup_files[keep_count:]:
with contextlib.suppress(Exception):
os.remove(file_path)
removed += 1
msg = f"Cleaned up {removed} old backup files"
if self.logger:
self.logger.info(msg)
if self.status_callback and removed:
self.status_callback(msg)
except Exception as e: # pragma: no cover - defensive
if self.logger:
self.logger.error(f"Backup cleanup failed: {e}")
def restore_from_backup(self, backup_path: str) -> bool:
if not os.path.exists(backup_path):
if self.logger:
self.logger.error(f"Backup file doesn't exist: {backup_path}")
return False
try:
# Create a backup of current data before restoring
current_backup = self.create_backup("pre_restore")
shutil.copy2(backup_path, self.data_file_path)
msg = f"Successfully restored from backup: {backup_path}"
if self.logger:
self.logger.info(msg)
if current_backup:
self.logger.info(f"Previous data backed up to: {current_backup}")
if self.status_callback:
self.status_callback(msg)
return True
except Exception as e: # pragma: no cover - defensive
if self.logger:
self.logger.error(f"Restore from backup failed: {e}")
return False
__all__ = ["AutoSaveManager", "BackupManager"]