# 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 keys exist. """ def __init__(self, logger): """ Initializes the ConfigManager with a logger instance. Args: logger (logging.Logger): Logger instance for logging messages. """ self.logger = logger self.config = configparser.ConfigParser() self.load_config() def load_config(self): """ Loads the configuration from the CONFIG_FILE. If the file doesn't exist or is missing sections/keys, it creates/adds them with default values. """ try: config_exists = os.path.exists(CONFIG_FILE) files_read = self.config.read( CONFIG_FILE, encoding="utf-8" ) # Specify encoding if not config_exists or not files_read: if ( config_exists and not files_read ): # File exists but was empty or unreadable self.logger.warning( f"Config file '{CONFIG_FILE}' exists but could not be read or is empty. Recreating with defaults." ) else: # File doesn't exist self.logger.info( f"Config file '{CONFIG_FILE}' not found. Creating with defaults." ) self.config = configparser.ConfigParser() # Ensure clean parser state self._create_default_profile() self.save_config() else: self.logger.debug( f"Successfully read configuration from '{CONFIG_FILE}'." ) # File exists and was read, ensure default profile and all keys are present profile_needs_save = False if DEFAULT_PROFILE not in self.config.sections(): self.logger.warning( f"Default profile '{DEFAULT_PROFILE}' missing. Adding it." ) self._create_default_profile() # Creates section and adds all keys profile_needs_save = True else: # Check if default profile is missing new keys (for backward compatibility) default_section = self.config[DEFAULT_PROFILE] # Define expected keys and their defaults here for check 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 ): # More robust check self.logger.warning( f"Key '{key}' missing in profile '{DEFAULT_PROFILE}'. Adding default value: '{default_value}'." ) self.config.set(DEFAULT_PROFILE, key, default_value) profile_needs_save = True if profile_needs_save: self.logger.info( f"Updating configuration file '{CONFIG_FILE}' with missing defaults." ) self.save_config() except configparser.Error as e: self.logger.error( f"Error parsing configuration file '{CONFIG_FILE}': {e}", exc_info=True ) self.logger.info( "Attempting to create a default configuration due to parsing error." ) self.config = configparser.ConfigParser() self._create_default_profile() self.save_config() # Save the new default config except Exception as e: self.logger.error( f"Unexpected error loading configuration: {e}", exc_info=True ) # Consider re-raising or exiting depending on severity def _get_expected_keys_with_defaults(self): """Returns a dictionary of expected config keys and their default values.""" 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, "autocommit": "False", "commit_message": "", # Added "backup_exclude_extensions": ".log,.tmp", # Added with example default } def _create_default_profile(self): """Creates the default profile section with default values for all keys.""" if DEFAULT_PROFILE not in self.config.sections(): self.config.add_section(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) self.logger.info( f"Ensured default profile '{DEFAULT_PROFILE}' exists with default values." ) def save_config(self): """ Saves the current configuration to the CONFIG_FILE. """ try: with open( CONFIG_FILE, "w", encoding="utf-8" ) as configfile: # Specify encoding self.config.write(configfile) self.logger.debug(f"Configuration saved to '{CONFIG_FILE}'.") except IOError as e: self.logger.error( f"Error writing configuration file '{CONFIG_FILE}': {e}", exc_info=True ) except Exception as e: self.logger.error( f"Unexpected error saving configuration: {e}", exc_info=True ) def get_profile_sections(self): """ Returns a list of all profile sections in the configuration file. Returns: list: List of profile section names. """ return self.config.sections() def get_profile_option(self, profile, option, fallback=None): """ Retrieves a specific option from a profile section. Args: profile (str): Name of the profile section. option (str): Name of the option to retrieve. fallback (any, optional): Default value to return if the option or profile is not found. Defaults to None. Returns: str: Value of the option or the fallback value if not found. Returns fallback also if the profile itself doesn't exist. """ # Use configparser's built-in fallback mechanism # The 'fallback' parameter in config.get() handles NoSectionError and NoOptionError try: # Ensure fallback is a string if not None, as configparser expects strings str_fallback = str(fallback) if fallback is not None else None # Check if section exists first for better logging if not self.config.has_section(profile): self.logger.warning( f"Profile '{profile}' not found. Cannot get option '{option}'. Using fallback." ) return fallback # Return original fallback type value = self.config.get(profile, option, fallback=str_fallback) # Log if fallback was used specifically because the option was missing if value == str_fallback and not self.config.has_option(profile, option): self.logger.warning( f"Option '{option}' not found in profile '{profile}'. Using fallback." ) return value # Return the retrieved value (or stringified fallback) except Exception as e: # Catch unexpected errors during retrieval self.logger.error( f"Error getting option '{option}' for profile '{profile}': {e}", exc_info=True, ) return fallback # Return the original fallback type def set_profile_option(self, profile, option, value): """ Sets a specific option in a profile section. Creates the section if it doesn't exist. Args: profile (str): Name of the profile section. option (str): Name of the option to set. value (any): Value to set for the option (will be converted to string). """ try: if not self.config.has_section(profile): self.config.add_section(profile) self.logger.info(f"Created new profile section: '{profile}'") # Ensure keys are stored in lowercase if needed for case-insensitivity later # configparser handles sections case-insensitively by default, keys case-sensitively self.config.set(profile, option, str(value)) # Ensure value is string self.logger.debug( f"Set option '{option}' = '{value}' in profile '{profile}'." ) except Exception as e: self.logger.error( f"Error setting option '{option}' in profile '{profile}': {e}", exc_info=True, ) def remove_profile_section(self, profile): """ Removes a profile section from the configuration. Args: profile (str): Name of the profile section to remove. Returns: bool: True if the section was removed successfully, False otherwise. """ if profile == DEFAULT_PROFILE: self.logger.warning( f"Attempted to remove the default profile '{DEFAULT_PROFILE}', which is not allowed." ) 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, but good to log self.logger.warning( f"ConfigParser reported failure removing section '{profile}' even though it existed." ) 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