# --- FILE: gitsync_tool/config/config_manager.py --- import configparser import os import sys # Per fallback print from typing import Optional, Dict, List, Any # Aggiunti type hints # ---<<< MODIFICA IMPORT >>>--- # Importa usando il percorso assoluto dal pacchetto gitsync_tool from gitutility.logging_setup import log_handler # ---<<< FINE MODIFICA IMPORT >>>--- # --- Constants --- CONFIG_FILE: str = "git_svn_sync.ini" # Nome file configurazione DEFAULT_PROFILE: str = "default" # Nome del profilo di default # Percorso di backup di default (usa os.path.join per compatibilità OS) DEFAULT_BACKUP_DIR: str = os.path.join( os.path.expanduser("~"), "git_sync_backups" ) # Nome cartella cambiato per chiarezza # Nome di default per il remote Git DEFAULT_REMOTE_NAME: str = "origin" class ConfigManager: """ Manages the application's configuration file (git_svn_sync.ini). Handles loading, saving, validating, and accessing profile settings. Uses the centralized log_handler for logging operations. Ensures a default profile exists and all expected keys are present across all profiles, adding defaults where necessary. """ def __init__( self, logger_ignored: Optional[Any] = None ): # Accetta argomento ma lo ignora """Initializes the ConfigManager and loads the configuration.""" # self.logger non è più usato # Initialize the config parser instance self.config: configparser.ConfigParser = configparser.ConfigParser() # Load and validate the configuration during initialization # Use try-except to handle critical errors during the initial load try: self.load_config() # This method now handles creation/validation log_handler.log_debug( "ConfigManager initialized and configuration loaded/validated.", func_name="__init__", ) except Exception as e: # Log critical failure if initial load/validation fails completely # Use print as a fallback since logging itself might depend on config/paths print( f"CRITICAL ERROR: Failed to initialize configuration: {e}", file=sys.stderr, ) log_handler.log_critical( f"Failed to load/validate initial configuration: {e}", func_name="__init__", ) # Initialize with an empty config parser to allow the app to potentially # start in a degraded state or prompt the user. self.config = configparser.ConfigParser() # Depending on app design, might want to raise RuntimeError here # raise RuntimeError("Failed to initialize configuration manager") from e def _get_expected_keys_with_defaults(self) -> Dict[str, str]: """ Returns a dictionary defining all expected config keys for a profile and their default string values. This serves as the single source of truth. """ # Define all expected keys and their default values here return { # Core Paths & Names "svn_working_copy_path": "", # Default to empty, user must provide "usb_drive_path": "", # Default to empty "bundle_name": "repository.bundle", "bundle_name_updated": "repository_update.bundle", # Backup Settings "autobackup": "False", # Use string "True"/"False" for ini compatibility "backup_dir": DEFAULT_BACKUP_DIR, # Use the defined constant "backup_exclude_extensions": ".log, .tmp, .bak, ~*", # Example defaults "backup_exclude_dirs": "__pycache__, .venv, .vscode, build, dist, node_modules, .git, .svn", # Extended defaults # Commit Settings "autocommit": "False", "commit_message": "", # Default empty commit message # Remote Settings "remote_url": "", # Default to empty, user needs to configure "remote_name": DEFAULT_REMOTE_NAME, # Use the defined constant 'origin' } def load_config(self) -> None: """ Loads configuration from CONFIG_FILE. Creates the file and/or default profile with all expected keys if they don't exist. Validates existing profiles to ensure all expected keys are present, adding defaults if missing. Uses the centralized log_handler for logging. """ func_name: str = "load_config" log_handler.log_debug( f"Attempting to load configuration from '{CONFIG_FILE}'...", func_name=func_name, ) config_needs_saving: bool = False # Flag to track if changes require saving try: config_exists: bool = os.path.exists(CONFIG_FILE) files_read: List[str] = [] if config_exists: try: # Attempt to read the configuration file using UTF-8 files_read = self.config.read(CONFIG_FILE, encoding="utf-8") # Handle case where file exists but is empty or unreadable by configparser if not files_read and config_exists: log_handler.log_warning( f"Configuration file '{CONFIG_FILE}' exists but is empty or could not be parsed. Will recreate with defaults.", func_name=func_name, ) # Reset config object to start fresh self.config = configparser.ConfigParser() config_exists = False # Treat as if file didn't exist except configparser.Error as read_err: # Handle parsing errors (e.g., invalid INI format) log_handler.log_error( f"Error parsing configuration file '{CONFIG_FILE}': {read_err}. Recreating file with defaults.", func_name=func_name, ) self.config = configparser.ConfigParser() config_exists = False # Treat as if file didn't exist # If file didn't exist or was invalid, create default structure if not config_exists: log_handler.log_info( f"Configuration file '{CONFIG_FILE}' not found or invalid. Creating a new one with the default profile.", func_name=func_name, ) # Ensure config object is clean (already done above if parsing failed) if self.config.sections(): # Should be empty, but check just in case self.config = configparser.ConfigParser() # Create the default profile with all expected keys self._create_default_profile() # Helper function to add section and keys config_needs_saving = True # Mark that the new config needs to be saved else: # Config file was read successfully, proceed with validation log_handler.log_debug( f"Successfully read configuration from '{CONFIG_FILE}'. Validating content...", func_name=func_name, ) # --- Content Validation --- expected_keys_defaults: Dict[str, str] = ( self._get_expected_keys_with_defaults() ) all_current_profiles: List[str] = list( self.config.sections() ) # Get copy of section names # 1. Ensure the default profile exists if DEFAULT_PROFILE not in all_current_profiles: log_handler.log_warning( f"Default profile '{DEFAULT_PROFILE}' is missing in the loaded config. Adding it with default values.", func_name=func_name, ) self._create_default_profile() # Create the default profile # Refresh the list of profiles if default was just added all_current_profiles = list(self.config.sections()) config_needs_saving = True # Mark for saving # 2. Validate keys for ALL existing profiles log_handler.log_debug( f"Validating keys for profiles: {all_current_profiles}", func_name=func_name, ) for profile_name in all_current_profiles: for key, default_value in expected_keys_defaults.items(): # Check if the profile section is missing the expected key if not self.config.has_option(profile_name, key): # Use the default value defined in _get_expected_keys_with_defaults log_handler.log_warning( f"Key '{key}' missing in profile '{profile_name}'. Adding it with default value: '{default_value}'.", func_name=func_name, ) # Add the missing key with its default value self.config.set(profile_name, key, default_value) config_needs_saving = True # Mark for saving # Save configuration if any changes were made during load/validation if config_needs_saving: log_handler.log_info( f"Configuration was updated during load/validation. Saving changes to '{CONFIG_FILE}'.", func_name=func_name, ) self.save_config() # Call save method except Exception as e: # Catch any unexpected errors during the load/validation process log_handler.log_exception( f"Unexpected error loading or validating configuration: {e}", func_name=func_name, ) # Optionally reset config to a safe state or re-raise self.config = configparser.ConfigParser() # raise RuntimeError("Critical configuration error during load") from e def _create_default_profile(self) -> None: """ Internal helper method to create the default profile section and populate it with all expected keys and their default values. """ func_name = "_create_default_profile" # Ensure the section exists if DEFAULT_PROFILE not in self.config.sections(): log_handler.log_debug( f"Adding default profile section: '{DEFAULT_PROFILE}'.", func_name=func_name, ) self.config.add_section(DEFAULT_PROFILE) # Get the standard keys and defaults expected_keys_defaults: Dict[str, str] = self._get_expected_keys_with_defaults() log_handler.log_debug( f"Setting default keys/values for profile '{DEFAULT_PROFILE}'.", func_name=func_name, ) # Set each expected key with its default value in the default profile for key, default_value in expected_keys_defaults.items(): # configparser's set() method handles adding/updating the option self.config.set(DEFAULT_PROFILE, key, default_value) log_handler.log_info( f"Ensured default profile '{DEFAULT_PROFILE}' exists with all expected keys.", func_name=func_name, ) def save_config(self) -> None: """ Saves the current configuration state (self.config) to the CONFIG_FILE. Uses UTF-8 encoding. Uses log_handler. """ func_name: str = "save_config" log_handler.log_debug( f"Attempting to save configuration to '{CONFIG_FILE}'...", func_name=func_name, ) # Optional: Check/Create directory if config file is specified with a path config_dir: str = os.path.dirname(CONFIG_FILE) # Ensure directory exists only if CONFIG_FILE includes a directory part if config_dir and not os.path.exists(config_dir): try: os.makedirs(config_dir) log_handler.log_info( f"Created directory for config file: {config_dir}", func_name=func_name, ) except OSError as e: # Log error but proceed with save attempt (might succeed in current dir) log_handler.log_error( f"Could not create directory '{config_dir}': {e}. Save attempt might fail.", func_name=func_name, ) try: # Write the config object to the file using 'w' mode and UTF-8 encoding with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: self.config.write(configfile) log_handler.log_info( f"Configuration saved successfully to '{CONFIG_FILE}'.", func_name=func_name, ) except IOError as e: # Log specific I/O errors during save log_handler.log_error( f"IOError writing config file '{CONFIG_FILE}': {e}", func_name=func_name ) # Optionally re-raise depending on desired application behavior # raise IOError(f"Failed to save config: {e}") from e except Exception as e: # Log any other unexpected errors during save log_handler.log_exception( f"Unexpected error saving configuration: {e}", func_name=func_name ) # raise RuntimeError(f"Unexpected error saving config: {e}") from e def get_profile_sections(self) -> List[str]: """ Returns a sorted list of all profile section names present in the configuration. """ # Sort alphabetically for consistent display order in UI elements return sorted(self.config.sections()) def get_profile_option( self, profile: str, option: str, fallback: Optional[str] = None ) -> Optional[str]: """ Retrieves a specific option's value from a profile section as a string. Handles missing sections or options gracefully using the fallback. Uses log_handler. Args: profile (str): Name of the profile section (e.g., 'default'). option (str): Name of the option key (e.g., 'remote_url'). fallback (Optional[str]): Value to return if the option or section is not found. Returns: Optional[str]: The option value as a string if found, otherwise the fallback value. Returns None if fallback is None and option is missing. """ func_name: str = "get_profile_option" try: # Check if the profile section exists first for better logging if not self.config.has_section(profile): log_handler.log_warning( f"Profile '{profile}' not found. Cannot get option '{option}'. Returning fallback: {repr(fallback)}", func_name=func_name, ) return fallback # Return the original fallback value # Use configparser's get method with its built-in fallback mechanism # Configparser always returns strings or the fallback if the fallback itself is provided. value_str: Optional[str] = self.config.get( profile, option, fallback=fallback ) # Log if fallback was potentially used (more informative debug log) # Note: This check isn't perfect if fallback could be the actual value, # but it helps in debugging missing keys. if value_str == fallback and not self.config.has_option(profile, option): log_handler.log_debug( f"Option '{option}' not found in profile '{profile}'. Used fallback value: {repr(fallback)}", func_name=func_name, ) # Return the value obtained (which will be a string or the original fallback) return value_str except Exception as e: # Log unexpected errors during option retrieval log_handler.log_exception( f"Unexpected error getting option '{option}' for profile '{profile}': {e}", func_name=func_name, ) # Return the fallback value in case of any error return fallback def set_profile_option(self, profile: str, option: str, value: Any) -> None: """ Sets an option in a specific profile section. Creates the section if needed. The provided value is automatically converted to a string for storage in the INI file. Uses log_handler. Args: profile (str): Name of the profile section. option (str): Name of the option key. value (Any): Value to set (will be converted to its string representation). None will be stored as an empty string. """ func_name: str = "set_profile_option" try: # Ensure the profile section exists, creating it if necessary if not self.config.has_section(profile): self.config.add_section(profile) log_handler.log_info( f"Created new profile section '{profile}' while setting option '{option}'.", func_name=func_name, ) # Convert the value to string for storage (handle None case) value_str: str = str(value) if value is not None else "" # Set the option using configparser's set method self.config.set(profile, option, value_str) log_handler.log_debug( f"Set option in profile '{profile}': {option} = '{value_str}'", func_name=func_name, ) except configparser.Error as e: # Log specific configparser errors (e.g., invalid section/option names) log_handler.log_error( f"ConfigParser error setting option '{option}' in profile '{profile}': {e}", func_name=func_name, ) except Exception as e: # Log other unexpected errors log_handler.log_exception( f"Unexpected error setting option '{option}' in profile '{profile}': {e}", func_name=func_name, ) def remove_profile_section(self, profile: str) -> bool: """ Removes a profile section entirely from the configuration. Prevents removal of the default profile. Uses log_handler. Args: profile (str): Name of the profile section to remove. Returns: bool: True if the section was successfully removed, False otherwise (e.g., profile not found or is the default profile). """ func_name: str = "remove_profile_section" # Prevent removal of the required default profile if profile == DEFAULT_PROFILE: log_handler.log_warning( f"Attempt to remove the default profile ('{DEFAULT_PROFILE}') was denied.", func_name=func_name, ) return False # Indicate failure # Check if the profile section actually exists if self.config.has_section(profile): try: # Attempt to remove the section using configparser removed: bool = self.config.remove_section(profile) if removed: log_handler.log_info( f"Successfully removed profile section: '{profile}'", func_name=func_name, ) return True # Indicate success else: # This case should be rare if has_section was True, but log it log_handler.log_warning( f"ConfigParser reported failure trying to remove existing section '{profile}'.", func_name=func_name, ) return False # Indicate failure except Exception as e: # Log any errors that occur during the removal process log_handler.log_exception( f"Error removing profile section '{profile}': {e}", func_name=func_name, ) return False # Indicate failure else: # Profile section was not found log_handler.log_warning( f"Profile section '{profile}' not found. Cannot remove.", func_name=func_name, ) return False # Indicate failure def add_section(self, section_name: str) -> bool: """ Adds a new section (profile) to the configuration if it doesn't already exist. Args: section_name (str): The name of the section (profile) to add. Returns: bool: True if the section was added, False if it already existed. """ # Helper function, mainly used internally by add_profile logic in controller if not self.config.has_section(section_name): self.config.add_section(section_name) log_handler.log_info( f"Added new configuration section: '{section_name}'", func_name="add_section", ) return True # Section was added else: log_handler.log_debug( f"Section '{section_name}' already exists. No action taken.", func_name="add_section", ) return False # Section already existed # --- END OF FILE gitsync_tool/config/config_manager.py ---