SXXXXXXX_GitUtility/config_manager.py
2025-04-18 12:15:42 +02:00

316 lines
14 KiB
Python

# 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