# config_manager.py import configparser import os import logging # Constants CONFIG_FILE = "git_svn_sync.ini" DEFAULT_PROFILE = "default" # This is the single source of truth for the default backup directory path DEFAULT_BACKUP_DIR = os.path.join(os.path.expanduser("~"), "git_svn_backup") class ConfigManager: """ Manages the configuration file for the Git SVN Sync tool. Handles loading, saving, and accessing configuration settings. Ensures default profile and all keys exist in all profiles. """ def __init__(self, logger): """ Initializes the ConfigManager with a logger instance. Args: logger (logging.Logger or logging.LoggerAdapter): Logger instance. """ if not isinstance(logger, (logging.Logger, logging.LoggerAdapter)): raise TypeError("logger must be a Logger or LoggerAdapter instance") self.logger = logger self.config = configparser.ConfigParser() # Carica la configurazione all'inizializzazione self.load_config() # Ensure config is loaded def _get_expected_keys_with_defaults(self): """ Returns a dictionary of expected config keys and their default values. This is the single source of truth for profile keys. """ return { "svn_working_copy_path": "/path/to/svn/working/copy", "usb_drive_path": "/media/usb", "bundle_name": "my_bundle.bundle", "bundle_name_updated": "my_updated_bundle.bundle", "autobackup": "False", "backup_dir": DEFAULT_BACKUP_DIR, # Use the global constant "autocommit": "False", "commit_message": "", "backup_exclude_extensions": ".log,.tmp", # Example default from original # --- MODIFICA: Aggiunta Nuova Chiave --- # Key to store comma-separated list of directory names to exclude from backup. "backup_exclude_dirs": "__pycache__,.venv,build,dist,node_modules", # --- FINE MODIFICA --- } def load_config(self): """ Loads the configuration from CONFIG_FILE. Creates the file with defaults if it doesn't exist. Ensures all profiles have all expected keys, adding defaults if missing. """ needs_save = ( False # Flag to track if config needs saving after loading/checking ) try: config_exists = os.path.exists(CONFIG_FILE) files_read = [] if config_exists: # Use try-except specifically for reading to handle potential encoding/parsing issues try: files_read = self.config.read(CONFIG_FILE, encoding="utf-8") if not files_read and config_exists: # File exists but was empty or unreadable by configparser self.logger.warning( f"Config file '{CONFIG_FILE}' exists but could not be read or is empty. Will recreate structure." ) # Reset parser and treat as if file didn't exist for default creation logic self.config = configparser.ConfigParser() config_exists = False # Force default profile creation below except configparser.Error as read_err: self.logger.error( f"Error parsing config file '{CONFIG_FILE}': {read_err}. Will attempt to recreate with defaults.", exc_info=True, ) self.config = configparser.ConfigParser() config_exists = False # Force default profile creation below if not config_exists: # File doesn't exist or failed to parse/was empty self.logger.info( f"Config file '{CONFIG_FILE}' not found or invalid. Creating with default profile and settings." ) self.config = configparser.ConfigParser() # Ensure clean parser state self._create_default_profile() needs_save = True # Need to save the newly created default profile else: self.logger.debug( f"Successfully read configuration from '{CONFIG_FILE}'. Validating profiles..." ) # File exists and was read, ensure default profile and all keys are present in all profiles # --- Ensure Default Profile Exists and Has All Keys --- if DEFAULT_PROFILE not in self.config.sections(): self.logger.warning( f"Default profile '{DEFAULT_PROFILE}' missing. Adding it with default values." ) self._create_default_profile() # Creates section and adds all keys needs_save = True else: # Default profile exists, check for missing keys expected_keys = self._get_expected_keys_with_defaults() for key, default_value in expected_keys.items(): if not self.config.has_option(DEFAULT_PROFILE, key): self.logger.warning( f"Key '{key}' missing in profile '{DEFAULT_PROFILE}'. Adding default: '{default_value}'." ) self.config.set(DEFAULT_PROFILE, key, default_value) needs_save = True # --- Ensure ALL Profiles Have ALL Keys (including the new backup_exclude_dirs) --- expected_keys = self._get_expected_keys_with_defaults() all_current_profiles = ( self.config.sections() ) # Get possibly updated list for profile_name in all_current_profiles: # Skip default, already checked if profile_name == DEFAULT_PROFILE: continue # Check each expected key for this profile for key, default_value in expected_keys.items(): if not self.config.has_option(profile_name, key): self.logger.warning( f"Key '{key}' missing in profile '{profile_name}'. Adding default: '{default_value}'." ) self.config.set(profile_name, key, default_value) needs_save = True # Mark for saving # Save the configuration file only if changes were made if needs_save: self.logger.info( f"Configuration updated with missing profiles/keys. Saving to '{CONFIG_FILE}'." ) self.save_config() except Exception as e: # Catch unexpected errors during the load/validation process self.logger.error( f"Unexpected error loading or validating configuration: {e}", exc_info=True, ) # Reset to a safe state (empty config) in case of severe issues self.config = configparser.ConfigParser() def _create_default_profile(self): """Creates the default profile section or ensures it has all default keys.""" if DEFAULT_PROFILE not in self.config.sections(): self.logger.debug(f"Adding default profile section '{DEFAULT_PROFILE}'.") self.config.add_section(DEFAULT_PROFILE) # Get expected keys and set them for the default profile expected_keys = self._get_expected_keys_with_defaults() for key, default_value in expected_keys.items(): self.config.set( DEFAULT_PROFILE, key, default_value ) # set() handles adding/updating self.logger.info( f"Ensured default profile '{DEFAULT_PROFILE}' exists with default keys/values." ) def save_config(self): """Saves the current configuration state to the CONFIG_FILE.""" # Check/Create directory if needed (from previous version, good practice) config_dir = os.path.dirname(CONFIG_FILE) if config_dir and not os.path.exists(config_dir): try: os.makedirs(config_dir) self.logger.info(f"Created directory for config file: {config_dir}") except OSError as e: self.logger.error( f"Could not create directory '{config_dir}': {e}. Save might fail.", exc_info=True, ) try: # Write the config object to the file with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: self.config.write(configfile) self.logger.debug(f"Configuration saved to '{CONFIG_FILE}'.") except IOError as e: self.logger.error( f"IOError writing configuration file '{CONFIG_FILE}': {e}", exc_info=True, ) # Consider raising the error depending on desired behavior except Exception as e: self.logger.error( f"Unexpected error saving configuration: {e}", exc_info=True ) def get_profile_sections(self): """Returns a sorted list of all profile section names.""" # Sort alphabetically for consistent display in UI return sorted(self.config.sections()) def get_profile_option(self, profile, option, fallback=None): """ Retrieves a specific option from a profile section, handling errors. Args: profile (str): Name of the profile section. option (str): Name of the option key. fallback (any, optional): Value to return if option/section not found or error. Returns: str or any: The option value as a string if found, otherwise the original fallback. """ try: # Check if the profile section exists first for clearer logging if not self.config.has_section(profile): self.logger.warning( f"Profile '{profile}' not found. Cannot get option '{option}'. Using fallback: {fallback}" ) return fallback # Return original fallback type # Use configparser's built-in fallback mechanism str_fallback = str(fallback) if fallback is not None else None value_str = self.config.get(profile, option, fallback=str_fallback) # Check if fallback was used because the option was missing option_was_missing = not self.config.has_option(profile, option) # Careful: if fallback is None, str_fallback is None. config.get might return None if option value is empty. # We need to distinguish between "option missing" and "option exists but is empty". if value_str == str_fallback and option_was_missing: self.logger.debug( f"Option '{option}' not found in profile '{profile}'. Used fallback: {fallback}" ) return fallback # Return original fallback type # Return the value obtained (always a string from configparser) return value_str except Exception as e: self.logger.error( f"Error getting option '{option}' for profile '{profile}': {e}", exc_info=True, ) return fallback # Return fallback on error def set_profile_option(self, profile, option, value): """ Sets an option in a profile section, creating the section if needed. Stores the value as a string. Args: profile (str): Name of the profile section. option (str): Name of the option key. value (any): Value to set (will be converted to string). """ try: # Create section if it doesn't exist if not self.config.has_section(profile): self.config.add_section(profile) self.logger.info(f"Created new profile section during set: '{profile}'") # Convert value to string for storage value_str = str(value) if value is not None else "" self.config.set(profile, option, value_str) self.logger.debug( f"Set option '{option}' = '{value_str}' in profile '{profile}'." ) except configparser.Error as e: self.logger.error( f"ConfigParser error setting option '{option}' in profile '{profile}': {e}", exc_info=True, ) except Exception as e: self.logger.error( f"Unexpected error setting option '{option}' in profile '{profile}': {e}", exc_info=True, ) def remove_profile_section(self, profile): """ Removes a profile section (cannot remove the default profile). Args: profile (str): Name of the profile section to remove. Returns: bool: True if removed successfully, False otherwise. """ if profile == DEFAULT_PROFILE: self.logger.warning( f"Attempt to remove default profile '{DEFAULT_PROFILE}' denied." ) return False if self.config.has_section(profile): try: removed = self.config.remove_section(profile) if removed: self.logger.info(f"Removed profile section: '{profile}'") return True else: # Should not happen if has_section was True self.logger.warning( f"ConfigParser failed to remove section '{profile}' despite existence." ) return False except Exception as e: self.logger.error( f"Error removing profile section '{profile}': {e}", exc_info=True ) return False else: self.logger.warning( f"Profile section '{profile}' not found, cannot remove." ) return False