import json import os import sys import shutil from pathlib import Path import logging import re import collections.abc from typing import Optional, Union log = logging.getLogger(__name__) # This BASE_DIR is primarily for fallback when not bundled or for locating bundled resources relative to the script. _SCRIPT_DIR = Path(__file__).resolve().parent class ConfigurationError(Exception): """Custom exception for configuration loading errors.""" pass def _get_user_config_path_placeholder() -> Optional[Path]: """ Placeholder function. In a real scenario, this would retrieve the saved user configuration path (e.g., from a settings file). Returns None if not set, triggering first-time setup behavior. """ # For this subtask, we assume this path is determined externally and passed to Configuration. # If we were to implement the settings.ini check here, it would look like: # try: # app_data_dir = Path(os.getenv('APPDATA')) / "AssetProcessor" # settings_ini = app_data_dir / "settings.ini" # if settings_ini.exists(): # with open(settings_ini, 'r') as f: # path_str = f.read().strip() # return Path(path_str) # except Exception: # return None return None def _get_base_map_type(target_map_string: str) -> str: """Extracts the base map type (e.g., 'COL') from a potentially numbered string ('COL-1').""" # Use regex to find the leading alphabetical part match = re.match(r"([a-zA-Z]+)", target_map_string) if match: return match.group(1).upper() # Fallback if no number suffix or unexpected format return target_map_string.upper() def _fnmatch_to_regex(pattern: str) -> str: """ Converts an fnmatch pattern to a regex pattern string. Handles basic wildcards (*, ?) and escapes other regex special characters. """ i, n = 0, len(pattern) res = '' while i < n: c = pattern[i] i = i + 1 if c == '*': res = res + '.*' elif c == '?': res = res + '.' elif c == '[': j = i if j < n and pattern[j] == '!': j = j + 1 if j < n and pattern[j] == ']': j = j + 1 while j < n and pattern[j] != ']': j = j + 1 if j >= n: res = res + '\\[' else: stuff = pattern[i:j].replace('\\','\\\\') i = j + 1 if stuff[0] == '!': stuff = '^' + stuff[1:] elif stuff[0] == '^': stuff = '\\' + stuff res = '%s[%s]' % (res, stuff) else: res = res + re.escape(c) # We want to find the pattern anywhere in the filename for flexibility, # so don't anchor with ^$ by default. Anchoring might be needed for specific cases. # Let's return the core pattern and let the caller decide on anchoring if needed. # For filename matching, we usually want to find the pattern, not match the whole string. return res def _deep_merge_dicts(base_dict: dict, override_dict: dict) -> dict: """ Recursively merges override_dict into base_dict. If a key exists in both and both values are dicts, it recursively merges them. Otherwise, the value from override_dict takes precedence. Modifies base_dict in place and returns it. """ for key, value in override_dict.items(): if isinstance(value, collections.abc.Mapping): node = base_dict.get(key) # Use .get() to avoid creating empty dicts if not needed for override if isinstance(node, collections.abc.Mapping): _deep_merge_dicts(node, value) # node is base_dict[key], modified in place else: # If base_dict[key] is not a dict or doesn't exist, override it base_dict[key] = value else: base_dict[key] = value return base_dict class Configuration: """ Loads and provides access to core settings combined with a specific preset, managing bundled and user-specific configuration paths. """ BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME = "config" PRESETS_DIR_APP_BUNDLED_NAME = "Presets" USER_SETTINGS_FILENAME = "user_settings.json" APP_SETTINGS_FILENAME = "app_settings.json" ASSET_TYPE_DEFINITIONS_FILENAME = "asset_type_definitions.json" FILE_TYPE_DEFINITIONS_FILENAME = "file_type_definitions.json" LLM_SETTINGS_FILENAME = "llm_settings.json" SUPPLIERS_CONFIG_FILENAME = "suppliers.json" USER_CONFIG_SUBDIR_NAME = "config" # Subdirectory within user's chosen config root for most jsons USER_PRESETS_SUBDIR_NAME = "Presets" # Subdirectory within user's chosen config root for presets def __init__(self, preset_name: str, base_dir_user_config: Optional[Path] = None, is_first_run_setup: bool = False): """ Loads core config, user overrides, and the specified preset file. Args: preset_name: The name of the preset (without .json extension). base_dir_user_config: The root path for user-specific configurations. If None, loading of user-specific files will be skipped or may fail. is_first_run_setup: Flag indicating if this is part of the initial setup process where user config dir might be empty and fallbacks should not aggressively try to copy from bundle until UI confirms. Raises: ConfigurationError: If critical configurations cannot be loaded/validated. """ log.debug(f"Initializing Configuration with preset: '{preset_name}', user_config_dir: '{base_dir_user_config}', first_run_flag: {is_first_run_setup}") self._preset_filename_stem = preset_name self.base_dir_user_config: Optional[Path] = base_dir_user_config self.is_first_run_setup = is_first_run_setup self.base_dir_app_bundled: Path = self._determine_base_dir_app_bundled() log.info(f"Determined BASE_DIR_APP_BUNDLED: {self.base_dir_app_bundled}") log.info(f"Using BASE_DIR_USER_CONFIG: {self.base_dir_user_config}") # 1. Load core application settings (always from bundled) app_settings_path = self.base_dir_app_bundled / self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME / self.APP_SETTINGS_FILENAME self._core_settings: dict = self._load_json_file( app_settings_path, is_critical=True, description="Core application settings" ) # 2. Load user settings (from user config dir, if provided) user_settings_overrides: dict = {} if self.base_dir_user_config: user_settings_file_path = self.base_dir_user_config / self.USER_SETTINGS_FILENAME user_settings_overrides = self._load_json_file( user_settings_file_path, is_critical=False, # Not critical if missing, especially on first run description=f"User settings from {user_settings_file_path}" ) or {} # Ensure it's a dict else: log.info(f"{self.USER_SETTINGS_FILENAME} not loaded: User config directory not set.") # 3. Deep merge user settings onto core settings if user_settings_overrides: log.info(f"Applying user setting overrides to core settings.") _deep_merge_dicts(self._core_settings, user_settings_overrides) # 4. Load other definition files (from user config dir, with fallback from bundled) self._asset_type_definitions: dict = self._load_definition_file_with_fallback( self.ASSET_TYPE_DEFINITIONS_FILENAME, "ASSET_TYPE_DEFINITIONS" ) self._file_type_definitions: dict = self._load_definition_file_with_fallback( self.FILE_TYPE_DEFINITIONS_FILENAME, "FILE_TYPE_DEFINITIONS" ) # --- Migration Logic for file_type_definitions.json --- # Moved from _load_definition_file_with_fallback to ensure execution if isinstance(self._file_type_definitions, dict): log.debug(f"Applying migration logic for old bit depth terminology in {self.FILE_TYPE_DEFINITIONS_FILENAME}") for map_type_key, definition in self._file_type_definitions.items(): if isinstance(definition, dict): # Check for old key "bit_depth_rule" if "bit_depth_rule" in definition: old_rule = definition.pop("bit_depth_rule") # Remove old key new_policy = old_rule # Start with the old value if old_rule == "respect": new_policy = "preserve" # Map old value to new elif old_rule == "respect_inputs": new_policy = "preserve" # Map old value to new (though this shouldn't be in FTD) elif old_rule == "": new_policy = "" # Keep empty string # "force_8bit" and "force_16bit" values remain the same definition["bit_depth_policy"] = new_policy # Add new key with migrated value log.warning(f"Migrated old 'bit_depth_rule': '{old_rule}' to 'bit_depth_policy': '{new_policy}' for map type '{map_type_key}' in {self.FILE_TYPE_DEFINITIONS_FILENAME}. Please update your configuration file.") # Also check for old value "respect" under the new key, in case the key was manually renamed but value wasn't if "bit_depth_policy" in definition and definition["bit_depth_policy"] == "respect": definition["bit_depth_policy"] = "preserve" log.warning(f"Migrated old 'bit_depth_policy' value 'respect' to 'preserve' for map type '{map_type_key}' in {self.FILE_TYPE_DEFINITIONS_FILENAME}. Please update your configuration file.") # --- Migration Logic for app_settings.json (MAP_MERGE_RULES) --- # This needs to happen after core settings are loaded and potentially merged with user settings, # so it might be better placed in __init__ after the merge, or in a dedicated method called by __init__. # For now, let's focus on the file_type_definitions.json issue causing the autotest warnings. # The app_settings.json migration can be a separate step if needed, but the primary issue # seems to be with file_type_definitions.json loading in the test context. self._llm_settings: dict = self._load_definition_file_with_fallback( self.LLM_SETTINGS_FILENAME, None # LLM settings might be flat (no root key) ) self._suppliers_config: dict = self._load_definition_file_with_fallback( self.SUPPLIERS_CONFIG_FILENAME, None # Suppliers config is flat ) # 5. Load preset settings (from user config dir, with fallback from bundled) self._preset_settings: dict = self._load_preset_with_fallback(self._preset_filename_stem) self.actual_internal_preset_name = self._preset_settings.get("preset_name", self._preset_filename_stem) log.info(f"Configuration instance: Loaded preset file '{self._preset_filename_stem}.json', internal preset_name is '{self.actual_internal_preset_name}'") # 6. Validate and compile (after all base/user/preset settings are established) self._validate_configs() self._compile_regex_patterns() log.info(f"Configuration loaded successfully using preset: '{self.actual_internal_preset_name}'") def _determine_base_dir_app_bundled(self) -> Path: """Determines the base directory for bundled application resources.""" if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): # Running in a PyInstaller bundle log.debug(f"Running as bundled app, _MEIPASS: {sys._MEIPASS}") return Path(sys._MEIPASS) else: # Running as a script log.debug(f"Running as script, using _SCRIPT_DIR: {_SCRIPT_DIR}") return _SCRIPT_DIR def _ensure_dir_exists(self, dir_path: Path): """Ensures a directory exists, creating it if necessary.""" try: if not dir_path.exists(): log.info(f"Directory not found, creating: {dir_path}") dir_path.mkdir(parents=True, exist_ok=True) elif not dir_path.is_dir(): raise ConfigurationError(f"Expected directory but found file: {dir_path}") except OSError as e: raise ConfigurationError(f"Failed to create or access directory {dir_path}: {e}") def _copy_default_if_missing(self, user_target_path: Path, bundled_source_subdir: str, filename: str) -> bool: """ Copies a default file from the bundled location to the user config directory if it's missing in the user directory. This is for post-first-time-setup fallback. """ if not self.base_dir_user_config: log.error(f"Cannot copy default for '{filename}': base_dir_user_config is not set.") return False if user_target_path.exists(): log.debug(f"User file '{user_target_path}' already exists. No copy needed from bundle.") return False # This fallback copy should NOT happen during the initial UI-driven setup phase # where the UI is responsible for the first population of the user directory. # It's for subsequent runs where a user might have deleted a file. if self.is_first_run_setup: log.debug(f"'{filename}' missing in user dir during first_run_setup phase. UI should handle initial copy. Skipping fallback copy.") return False # File is missing, but UI should handle it. bundled_file_path = self.base_dir_app_bundled / bundled_source_subdir / filename if not bundled_file_path.is_file(): log.warning(f"Default bundled file '{bundled_file_path}' not found. Cannot copy to user location '{user_target_path}'.") return False log.warning(f"User file '{user_target_path}' is missing. Attempting to restore from bundled default: '{bundled_file_path}'.") try: self._ensure_dir_exists(user_target_path.parent) shutil.copy2(bundled_file_path, user_target_path) log.info(f"Successfully copied '{bundled_file_path}' to '{user_target_path}'.") return True # File was copied except Exception as e: log.error(f"Failed to copy '{bundled_file_path}' to '{user_target_path}': {e}") return False # Copy failed def _load_json_file(self, file_path: Optional[Path], is_critical: bool = False, description: str = "configuration") -> dict: """Loads a JSON file, handling errors. Returns empty dict if not found and not critical.""" if not file_path: if is_critical: raise ConfigurationError(f"Critical {description} file path is not defined.") log.debug(f"{description} file path is not defined. Returning empty dict.") return {} log.debug(f"Attempting to load {description} from: {file_path}") if not file_path.is_file(): if is_critical: raise ConfigurationError(f"Critical {description} file not found: {file_path}") log.info(f"{description} file not found: {file_path}. Returning empty dict.") return {} try: with open(file_path, 'r', encoding='utf-8') as f: settings = json.load(f) log.debug(f"{description} loaded successfully from {file_path}.") return settings except json.JSONDecodeError as e: msg = f"Failed to parse {description} file {file_path}: Invalid JSON - {e}" if is_critical: raise ConfigurationError(msg) log.warning(msg + ". Returning empty dict.") return {} except Exception as e: msg = f"Failed to read {description} file {file_path}: {e}" if is_critical: raise ConfigurationError(msg) log.warning(msg + ". Returning empty dict.") return {} def _load_definition_file_with_fallback(self, filename: str, root_key: Optional[str] = None) -> dict: """ Loads a definition JSON file from the user config subdir. If not found and not first_run_setup, attempts to copy from bundled config subdir and then loads it. If base_dir_user_config is not set, loads directly from bundled (read-only). """ data = {} user_file_path = None if self.base_dir_user_config: user_file_path = self.base_dir_user_config / self.USER_CONFIG_SUBDIR_NAME / filename data = self._load_json_file(user_file_path, is_critical=False, description=f"User {filename}") if not data: # If not found or failed to load from user path # Attempt fallback copy only if not in the initial setup phase by UI # and if the file was genuinely missing (not a parse error for an existing file) if not user_file_path.exists() and not self.is_first_run_setup: if self._copy_default_if_missing(user_file_path, self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME, filename): data = self._load_json_file(user_file_path, is_critical=False, description=f"User {filename} after copy") else: # No user_config_dir, load directly from bundled (read-only) log.warning(f"User config directory not set. Loading '{filename}' from bundled defaults (read-only).") bundled_path = self.base_dir_app_bundled / self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME / filename data = self._load_json_file(bundled_path, is_critical=False, description=f"Bundled {filename}") if not data: # If still no data, it's an issue, especially for critical definitions is_critical_def = filename in [self.ASSET_TYPE_DEFINITIONS_FILENAME, self.FILE_TYPE_DEFINITIONS_FILENAME] err_msg = f"Failed to load '{filename}' from user dir '{user_file_path if user_file_path else 'N/A'}' or bundled defaults. Critical functionality may be affected." if is_critical_def: raise ConfigurationError(err_msg) log.error(err_msg) return {} if root_key: if root_key not in data: raise ConfigurationError(f"Key '{root_key}' not found in loaded {filename} data: {data.keys()}") content = data[root_key] # Ensure content is a dictionary if a root_key is expected to yield one if not isinstance(content, dict): raise ConfigurationError(f"Content under root key '{root_key}' in {filename} must be a dictionary, got {type(content)}.") return content return data # For flat files def _load_preset_with_fallback(self, preset_name_stem: str) -> dict: """ Loads a preset JSON file from the user's Presets subdir. If not found and not first_run_setup, attempts to copy from bundled Presets and then loads it. If base_dir_user_config is not set, loads directly from bundled (read-only). """ preset_filename = f"{preset_name_stem}.json" preset_data = {} user_preset_file_path = None if self.base_dir_user_config: user_presets_dir = self.base_dir_user_config / self.USER_PRESETS_SUBDIR_NAME user_preset_file_path = user_presets_dir / preset_filename preset_data = self._load_json_file(user_preset_file_path, is_critical=False, description=f"User preset '{preset_filename}'") if not preset_data: # If not found or failed to load if not user_preset_file_path.exists() and not self.is_first_run_setup: if self._copy_default_if_missing(user_preset_file_path, self.PRESETS_DIR_APP_BUNDLED_NAME, preset_filename): preset_data = self._load_json_file(user_preset_file_path, is_critical=False, description=f"User preset '{preset_filename}' after copy") else: log.warning(f"User config directory not set. Loading preset '{preset_filename}' from bundled defaults (read-only).") bundled_presets_dir = self.base_dir_app_bundled / self.PRESETS_DIR_APP_BUNDLED_NAME bundled_preset_file_path = bundled_presets_dir / preset_filename # Presets are generally critical for operation if one is specified preset_data = self._load_json_file(bundled_preset_file_path, is_critical=True, description=f"Bundled preset '{preset_filename}'") if not preset_data: raise ConfigurationError(f"Preset file '{preset_filename}' could not be loaded from user dir '{user_preset_file_path if user_preset_file_path else 'N/A'}' or bundled defaults.") return preset_data def _compile_regex_patterns(self): """Compiles regex patterns from config/preset for faster matching.""" log.debug("Compiling regex patterns from configuration...") self.compiled_extra_regex: list[re.Pattern] = [] self.compiled_model_regex: list[re.Pattern] = [] self.compiled_bit_depth_regex_map: dict[str, re.Pattern] = {} # Map: base_map_type -> list of tuples: (compiled_regex, original_keyword, rule_index, is_priority) self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int, bool]]] = {} for pattern in self.move_to_extra_patterns: try: regex_str = _fnmatch_to_regex(pattern) self.compiled_extra_regex.append(re.compile(regex_str, re.IGNORECASE)) except re.error as e: log.warning(f"Failed to compile 'extra' regex pattern '{pattern}': {e}. Skipping pattern.") model_patterns = self.asset_category_rules.get('model_patterns', []) for pattern in model_patterns: try: regex_str = _fnmatch_to_regex(pattern) self.compiled_model_regex.append(re.compile(regex_str, re.IGNORECASE)) except re.error as e: log.warning(f"Failed to compile 'model' regex pattern '{pattern}': {e}. Skipping pattern.") for map_type, pattern in self.source_bit_depth_variants.items(): try: regex_str = _fnmatch_to_regex(pattern) if pattern.endswith('*'): regex_str = regex_str.removesuffix('.*') final_regex_str = regex_str self.compiled_bit_depth_regex_map[map_type] = re.compile(final_regex_str, re.IGNORECASE) log.debug(f" Compiled bit depth variant for '{map_type}' as regex (IGNORECASE): {final_regex_str}") except re.error as e: log.warning(f"Failed to compile 'bit depth' regex pattern '{pattern}' for map type '{map_type}': {e}. Skipping pattern.") separator = re.escape(self.source_naming_separator) from collections import defaultdict temp_compiled_map_regex = defaultdict(list) for rule_index, mapping_rule in enumerate(self.map_type_mapping): if not isinstance(mapping_rule, dict) or \ 'target_type' not in mapping_rule: # Removed 'keywords' check here as it's handled below log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type'.") continue target_type = mapping_rule['target_type'].upper() # Ensure 'keywords' exists and is a list, default to empty list if not found or not a list regular_keywords = mapping_rule.get('keywords', []) if not isinstance(regular_keywords, list): log.warning(f"Rule {rule_index} for target '{target_type}' has 'keywords' but it's not a list. Treating as empty.") regular_keywords = [] priority_keywords = mapping_rule.get('priority_keywords', []) # Optional, defaults to empty list if not isinstance(priority_keywords, list): log.warning(f"Rule {rule_index} for target '{target_type}' has 'priority_keywords' but it's not a list. Treating as empty.") priority_keywords = [] # Process regular keywords for keyword in regular_keywords: if not isinstance(keyword, str): log.warning(f"Skipping non-string regular keyword '{keyword}' in rule {rule_index} for target '{target_type}'.") continue try: kw_regex_part = _fnmatch_to_regex(keyword) # Ensure the keyword is treated as a whole word or is at the start/end of a segment regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})" compiled_regex = re.compile(regex_str, re.IGNORECASE) # Add False for is_priority temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index, False)) log.debug(f" Compiled regular keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}") except re.error as e: log.warning(f"Failed to compile regular map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.") # Process priority keywords for keyword in priority_keywords: if not isinstance(keyword, str): log.warning(f"Skipping non-string priority keyword '{keyword}' in rule {rule_index} for target '{target_type}'.") continue try: kw_regex_part = _fnmatch_to_regex(keyword) regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})" compiled_regex = re.compile(regex_str, re.IGNORECASE) # Add True for is_priority temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index, True)) log.debug(f" Compiled priority keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}") except re.error as e: log.warning(f"Failed to compile priority map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.") self.compiled_map_keyword_regex = dict(temp_compiled_map_regex) log.debug(f"Compiled map keyword regex keys: {list(self.compiled_map_keyword_regex.keys())}") log.debug("Finished compiling regex patterns.") def _validate_configs(self): """Performs basic validation checks on loaded settings.""" log.debug("Validating loaded configurations...") # Validate new definition files first if not isinstance(self._asset_type_definitions, dict): raise ConfigurationError("Asset type definitions were not loaded correctly or are not a dictionary.") if not self._asset_type_definitions: # Check if empty raise ConfigurationError("Asset type definitions are empty.") if not isinstance(self._file_type_definitions, dict): raise ConfigurationError("File type definitions were not loaded correctly or are not a dictionary.") if not self._file_type_definitions: # Check if empty raise ConfigurationError("File type definitions are empty.") # Preset validation required_preset_keys = [ "preset_name", "supplier_name", "source_naming", "map_type_mapping", "asset_category_rules", "archetype_rules", "move_to_extra_patterns" ] for key in required_preset_keys: if key not in self._preset_settings: raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json' (internal name: '{self.actual_internal_preset_name}') is missing required key: '{key}'.") # Validate map_type_mapping structure (new format) if not isinstance(self._preset_settings['map_type_mapping'], list): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': 'map_type_mapping' must be a list.") for index, rule in enumerate(self._preset_settings['map_type_mapping']): if not isinstance(rule, dict): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' must be a dictionary.") if 'target_type' not in rule or not isinstance(rule['target_type'], str): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.") valid_file_type_keys = self._file_type_definitions.keys() if rule['target_type'] not in valid_file_type_keys: raise ConfigurationError( f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' " f"has an invalid 'target_type': '{rule['target_type']}'. " f"Must be one of {list(valid_file_type_keys)}." ) # 'keywords' is optional if 'priority_keywords' is present and not empty, # but if 'keywords' IS present, it must be a list of strings. if 'keywords' in rule: if not isinstance(rule['keywords'], list): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' has 'keywords' but it's not a list.") for kw_index, keyword in enumerate(rule['keywords']): if not isinstance(keyword, str): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.") elif not ('priority_keywords' in rule and rule['priority_keywords']): # if 'keywords' is not present, 'priority_keywords' must be raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' must have 'keywords' or non-empty 'priority_keywords'.") # Validate priority_keywords if present if 'priority_keywords' in rule: if not isinstance(rule['priority_keywords'], list): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Rule at index {index} in 'map_type_mapping' has 'priority_keywords' but it's not a list.") for prio_kw_index, prio_keyword in enumerate(rule['priority_keywords']): if not isinstance(prio_keyword, str): raise ConfigurationError(f"Preset file '{self._preset_filename_stem}.json': Priority keyword at index {prio_kw_index} in rule {index} ('{rule['target_type']}') must be a string.") if not isinstance(self._core_settings.get('OUTPUT_DIRECTORY_PATTERN'), str): raise ConfigurationError("Core config 'OUTPUT_DIRECTORY_PATTERN' must be a string.") if not isinstance(self._core_settings.get('OUTPUT_FILENAME_PATTERN'), str): raise ConfigurationError("Core config 'OUTPUT_FILENAME_PATTERN' must be a string.") if not isinstance(self._core_settings.get('IMAGE_RESOLUTIONS'), dict): raise ConfigurationError("Core config 'IMAGE_RESOLUTIONS' must be a dictionary.") # Validate DEFAULT_ASSET_CATEGORY valid_asset_type_keys = self._asset_type_definitions.keys() default_asset_category_value = self._core_settings.get('DEFAULT_ASSET_CATEGORY') if not default_asset_category_value: raise ConfigurationError("Core config 'DEFAULT_ASSET_CATEGORY' is missing.") if default_asset_category_value not in valid_asset_type_keys: raise ConfigurationError( f"Core config 'DEFAULT_ASSET_CATEGORY' ('{default_asset_category_value}') " f"is not a valid key in ASSET_TYPE_DEFINITIONS. " f"Must be one of {list(valid_asset_type_keys)}." ) if self._llm_settings: required_llm_keys = [ "llm_predictor_examples", "llm_endpoint_url", "llm_api_key", "llm_model_name", "llm_temperature", "llm_request_timeout", "llm_predictor_prompt" ] for key in required_llm_keys: if key not in self._llm_settings: # Log warning instead of raising error to allow partial functionality log.warning(f"LLM config is missing recommended key: '{key}'. LLM features might not work correctly.") log.debug("Configuration validation passed.") @property def supplier_name(self) -> str: # From preset return self._preset_settings.get('supplier_name', 'DefaultSupplier') @property def suppliers_config(self) -> dict: # From suppliers.json """Returns the loaded suppliers configuration.""" return self._suppliers_config @property def internal_display_preset_name(self) -> str: """Returns the 'preset_name' field from within the loaded preset JSON, or falls back to the filename stem if not present.""" return self.actual_internal_preset_name @property def default_asset_category(self) -> str: """Gets the default asset category from core settings.""" # Fallback should align with a valid key, and validation should catch issues. return self._core_settings.get('DEFAULT_ASSET_CATEGORY', 'Surface') @property def target_filename_pattern(self) -> str: return self._core_settings['TARGET_FILENAME_PATTERN'] @property def output_directory_pattern(self) -> str: """Gets the output directory pattern ONLY from core settings.""" # Default pattern if missing in core settings (should be caught by validation) default_pattern = "[supplier]/[assetname]" return self._core_settings.get('OUTPUT_DIRECTORY_PATTERN', default_pattern) @property def output_filename_pattern(self) -> str: """Gets the output filename pattern ONLY from core settings.""" # Default pattern if missing in core settings (should be caught by validation) default_pattern = "[assetname]_[maptype]_[resolution].[ext]" return self._core_settings.get('OUTPUT_FILENAME_PATTERN', default_pattern) @property def image_resolutions(self) -> dict[str, int]: return self._core_settings['IMAGE_RESOLUTIONS'] @property def map_type_mapping(self) -> list: return self._preset_settings['map_type_mapping'] @property def source_naming_separator(self) -> str: return self._preset_settings.get('source_naming', {}).get('separator', '_') @property def source_naming_indices(self) -> dict: return self._preset_settings.get('source_naming', {}).get('part_indices', {}) @property def source_glossiness_keywords(self) -> list: return self._preset_settings.get('source_naming', {}).get('glossiness_keywords', []) @property def source_bit_depth_variants(self) -> dict: return self._preset_settings.get('source_naming', {}).get('bit_depth_variants', {}) @property def archetype_rules(self) -> list: return self._preset_settings['archetype_rules'] @property def asset_category_rules(self) -> dict: return self._preset_settings['asset_category_rules'] @property def move_to_extra_patterns(self) -> list: return self._preset_settings['move_to_extra_patterns'] @property def extra_files_subdir(self) -> str: return self._core_settings['EXTRA_FILES_SUBDIR'] @property def metadata_filename(self) -> str: return self._core_settings['METADATA_FILENAME'] @property def calculate_stats_resolution(self) -> str: return self._core_settings['CALCULATE_STATS_RESOLUTION'] @property def map_merge_rules(self) -> list: return self._core_settings['MAP_MERGE_RULES'] @property def aspect_ratio_decimals(self) -> int: return self._core_settings['ASPECT_RATIO_DECIMALS'] @property def temp_dir_prefix(self) -> str: return self._core_settings['TEMP_DIR_PREFIX'] @property def jpg_quality(self) -> int: """Gets the configured JPG quality level.""" return self._core_settings.get('JPG_QUALITY', 95) @property def invert_normal_green_globally(self) -> bool: """Gets the global setting for inverting the green channel of normal maps.""" # Default to False if the setting is missing in the core config return self._core_settings.get('invert_normal_map_green_channel_globally', False) @property def overwrite_existing(self) -> bool: """Gets the setting for overwriting existing files from core settings.""" return self._core_settings.get('overwrite_existing', False) @property def png_compression_level(self) -> int: """Gets the PNG compression level from core settings.""" return self._core_settings.get('PNG_COMPRESSION', 6) # Default to 6 if not found @property def resolution_threshold_for_jpg(self) -> int: """Gets the pixel dimension threshold for using JPG for 8-bit images.""" value = self._core_settings.get('RESOLUTION_THRESHOLD_FOR_JPG', 4096) log.info(f"CONFIGURATION_DEBUG: resolution_threshold_for_jpg property returning: {value} (type: {type(value)})") # Ensure it's an int, as downstream might expect it. # The .get() default is an int, but if the JSON had null or a string, it might be different. if not isinstance(value, int): log.warning(f"CONFIGURATION_DEBUG: RESOLUTION_THRESHOLD_FOR_JPG was not an int, got {type(value)}. Defaulting to 4096.") return 4096 return value @property def respect_variant_map_types(self) -> list: """Gets the list of map types that should always respect variant numbering.""" # Ensure it returns a list, even if missing from config.py (though defaults should handle it) return self._core_settings.get('RESPECT_VARIANT_MAP_TYPES', []) @property def force_lossless_map_types(self) -> list: """Gets the list of map types that must always be saved losslessly.""" return self._core_settings.get('FORCE_LOSSLESS_MAP_TYPES', []) def get_bit_depth_policy(self, map_type_input: str) -> str: """ Gets the bit depth policy ('force_8bit', 'force_16bit', 'preserve', '') for a given map type identifier. The map_type_input can be an FTD key (e.g., "MAP_COL") or a suffixed FTD key (e.g., "MAP_COL-1"). """ if not self._file_type_definitions: # Check if the attribute exists and is not empty log.warning("File type definitions not loaded. Cannot determine bit depth policy.") return "preserve" # Defaulting to 'preserve' as per refactor plan Phase 1 completion file_type_definitions = self._file_type_definitions # 1. Try direct match with map_type_input as FTD key definition = file_type_definitions.get(map_type_input) if definition: policy = definition.get('bit_depth_policy') # Valid policies include the empty string if policy in ['force_8bit', 'force_16bit', 'preserve', '']: return policy else: log.warning(f"FTD key '{map_type_input}' found, but 'bit_depth_policy' is missing or invalid: '{policy}'. Defaulting to 'preserve'.") return "preserve" # 2. Try to derive base FTD key by stripping common variant suffixes # Regex to remove trailing suffixes like -, -, _ base_ftd_key_candidate = re.sub(r"(-[\w\d]+|_[\w\d]+)$", "", map_type_input) if base_ftd_key_candidate != map_type_input: definition = file_type_definitions.get(base_ftd_key_candidate) if definition: policy = definition.get('bit_depth_policy') if policy in ['force_8bit', 'force_16bit', 'preserve', '']: log.debug(f"Derived base FTD key '{base_ftd_key_candidate}' from '{map_type_input}' and found bit depth policy: {policy}") return policy else: log.warning(f"Derived base FTD key '{base_ftd_key_candidate}' from '{map_type_input}', but 'bit_depth_policy' is missing/invalid: '{policy}'. Defaulting to 'preserve'.") return "preserve" # If no match found after trying direct and derived keys log.warning(f"Map type identifier '{map_type_input}' (or its derived base) not found in FILE_TYPE_DEFINITIONS. Defaulting bit depth policy to 'preserve'.") return "preserve" def get_16bit_output_formats(self) -> tuple[str, str]: """Gets the primary and fallback format names for 16-bit output.""" primary = self._core_settings.get('OUTPUT_FORMAT_16BIT_PRIMARY', 'png') fallback = self._core_settings.get('OUTPUT_FORMAT_16BIT_FALLBACK', 'png') return primary.lower(), fallback.lower() def get_8bit_output_format(self) -> str: """Gets the format name for 8-bit output.""" return self._core_settings.get('OUTPUT_FORMAT_8BIT', 'png').lower() def get_standard_map_type_aliases(self) -> list[str]: """ Derives a sorted list of unique standard map type aliases from FILE_TYPE_DEFINITIONS. """ aliases = set() # _file_type_definitions is guaranteed to be a dict by the loader for _key, definition in self._file_type_definitions.items(): if isinstance(definition, dict): standard_type = definition.get('standard_type') if standard_type and isinstance(standard_type, str) and standard_type.strip(): aliases.add(standard_type) return sorted(list(aliases)) def get_asset_type_definitions(self) -> dict: """Returns the _asset_type_definitions dictionary.""" return self._asset_type_definitions def get_asset_type_keys(self) -> list: """Returns a list of valid asset type keys from core settings.""" return list(self.get_asset_type_definitions().keys()) def get_file_type_definitions_with_examples(self) -> dict: """Returns the _file_type_definitions dictionary (including descriptions and examples).""" return self._file_type_definitions def get_file_type_keys(self) -> list: """Returns a list of valid file type keys from core settings.""" return list(self.get_file_type_definitions_with_examples().keys()) def get_llm_examples(self) -> list: """Returns the list of LLM input/output examples from LLM settings.""" # Use empty list as fallback if LLM settings file is missing/invalid return self._llm_settings.get('llm_predictor_examples', []) @property def llm_predictor_prompt(self) -> str: """Returns the LLM predictor prompt string from LLM settings.""" return self._llm_settings.get('llm_predictor_prompt', '') @property def llm_endpoint_url(self) -> str: """Returns the LLM endpoint URL from LLM settings.""" return self._llm_settings.get('llm_endpoint_url', '') @property def llm_api_key(self) -> str: """Returns the LLM API key from LLM settings.""" return self._llm_settings.get('llm_api_key', '') @property def llm_model_name(self) -> str: """Returns the LLM model name from LLM settings.""" return self._llm_settings.get('llm_model_name', '') @property def llm_temperature(self) -> float: """Returns the LLM temperature from LLM settings.""" return self._llm_settings.get('llm_temperature', 0.5) @property def llm_request_timeout(self) -> int: """Returns the LLM request timeout in seconds from LLM settings.""" return self._llm_settings.get('llm_request_timeout', 120) @property def app_version(self) -> Optional[str]: """Returns the application version from general_settings.""" gs = self._core_settings.get('general_settings') if isinstance(gs, dict): return gs.get('app_version') return None @property def enable_low_resolution_fallback(self) -> bool: """Gets the setting for enabling low-resolution fallback.""" return self._core_settings.get('ENABLE_LOW_RESOLUTION_FALLBACK', True) @property def low_resolution_threshold(self) -> int: """Gets the pixel dimension threshold for low-resolution fallback.""" return self._core_settings.get('LOW_RESOLUTION_THRESHOLD', 512) @property def FILE_TYPE_DEFINITIONS(self) -> dict: # Kept for compatibility if used directly return self._file_type_definitions # --- Save Methods --- def _save_json_to_user_config(self, data_to_save: dict, filename: str, subdir: Optional[str] = None, is_root_key_data: Optional[str] = None): """Helper to save a dictionary to a JSON file in the user config directory.""" if not self.base_dir_user_config: raise ConfigurationError(f"Cannot save {filename}: User config directory (base_dir_user_config) is not set.") target_dir = self.base_dir_user_config if subdir: target_dir = target_dir / subdir self._ensure_dir_exists(target_dir) path = target_dir / filename data_for_json = {is_root_key_data: data_to_save} if is_root_key_data else data_to_save log.debug(f"Saving data to: {path}") try: with open(path, 'w', encoding='utf-8') as f: json.dump(data_for_json, f, indent=4) log.info(f"Data saved successfully to {path}") except Exception as e: log.error(f"Failed to save file {path}: {e}") raise ConfigurationError(f"Failed to save {filename}: {e}") def save_user_settings(self, settings_dict: dict): """Saves the provided settings dictionary to user_settings.json in the user config directory.""" self._save_json_to_user_config(settings_dict, self.USER_SETTINGS_FILENAME) def save_llm_settings(self, settings_dict: dict): """Saves LLM settings to the user config directory's 'config' subdir.""" self._save_json_to_user_config(settings_dict, self.LLM_SETTINGS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME) def save_asset_type_definitions(self, data: dict): """Saves asset type definitions to the user config directory's 'config' subdir.""" self._save_json_to_user_config(data, self.ASSET_TYPE_DEFINITIONS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME, is_root_key_data="ASSET_TYPE_DEFINITIONS") def save_file_type_definitions(self, data: dict): """Saves file type definitions to the user config directory's 'config' subdir.""" self._save_json_to_user_config(data, self.FILE_TYPE_DEFINITIONS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME, is_root_key_data="FILE_TYPE_DEFINITIONS") def save_supplier_settings(self, data: dict): """Saves supplier settings to the user config directory's 'config' subdir.""" self._save_json_to_user_config(data, self.SUPPLIERS_CONFIG_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME) def save_preset(self, preset_data: dict, preset_name_stem: str): """Saves a preset to the user config directory's 'Presets' subdir.""" if not preset_name_stem: raise ConfigurationError("Preset name stem cannot be empty for saving.") preset_filename = f"{preset_name_stem}.json" # Ensure the preset_data itself contains the correct 'preset_name' field # or update it before saving if necessary. # For example: preset_data['preset_name'] = preset_name_stem self._save_json_to_user_config(preset_data, preset_filename, subdir=self.USER_PRESETS_SUBDIR_NAME) @property def keybind_config(self) -> dict[str, list[str]]: """ Processes FILE_TYPE_DEFINITIONS to create a mapping of keybinds to their associated file type keys. Example: {'C': ['MAP_COL'], 'R': ['MAP_ROUGH', 'MAP_GLOSS']} """ keybinds = {} # _file_type_definitions is guaranteed to be a dict by the loader for ftd_key, ftd_value in self._file_type_definitions.items(): if isinstance(ftd_value, dict) and 'keybind' in ftd_value: key = ftd_value['keybind'] if key not in keybinds: keybinds[key] = [] keybinds[key].append(ftd_key) # Ensure toggleable keybinds have their file types in a consistent order if necessary # For example, for 'R': ['MAP_ROUGH', 'MAP_GLOSS'] # The order from app_settings.json is generally preserved by dict iteration in Python 3.7+ # but explicit sorting could be added if a specific cycle order is critical beyond config file order. # For now, we rely on the order they appear in the config. return keybinds # The global load_base_config() is effectively replaced by Configuration.__init__ # Global save/load functions for individual files are refactored to be methods # of the Configuration class or called by them, using instance paths. # For example, to get a list of preset names, one might need a static method # or a function that knows about both bundled and user preset directories. def get_available_preset_names(base_dir_user_config: Optional[Path], base_dir_app_bundled: Path) -> list[str]: """ Gets a list of available preset names (stems) by looking in user presets and then bundled presets. User presets take precedence. """ preset_names = set() # Check user presets first if base_dir_user_config: user_presets_dir = base_dir_user_config / Configuration.USER_PRESETS_SUBDIR_NAME if user_presets_dir.is_dir(): for f in user_presets_dir.glob("*.json"): preset_names.add(f.stem) # Check bundled presets bundled_presets_dir = base_dir_app_bundled / Configuration.PRESETS_DIR_APP_BUNDLED_NAME if bundled_presets_dir.is_dir(): for f in bundled_presets_dir.glob("*.json"): preset_names.add(f.stem) # Adds if not already present from user dir if not preset_names: log.warning("No preset files found in user or bundled preset directories.") # Consider adding a default/template preset if none are found, or ensure one always exists in bundle. # For now, return empty list. return sorted(list(preset_names)) # Global functions like load_asset_definitions, save_asset_definitions etc. # are now instance methods of the Configuration class (e.g., self.save_asset_type_definitions). # If any external code was calling these global functions, it will need to be updated # to instantiate a Configuration object and call its methods, or these global # functions need to be carefully adapted to instantiate Configuration internally # or accept a Configuration instance. # For now, let's assume the primary interaction is via Configuration instance. # The old global functions below this point are effectively deprecated by the class methods. # I will remove them to avoid confusion and ensure all save/load operations # are managed through the Configuration instance with correct path context. # Removing old global load/save functions as their logic is now # part of the Configuration class or replaced by its new loading/saving mechanisms. # load_base_config() - Replaced by Configuration.__init__() # save_llm_config(settings_dict: dict) - Replaced by Configuration.save_llm_settings() # save_user_config(settings_dict: dict) - Replaced by Configuration.save_user_settings() # save_base_config(settings_dict: dict) - Bundled app_settings.json should be read-only. # load_asset_definitions() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic # save_asset_definitions(data: dict) - Replaced by Configuration.save_asset_type_definitions() # load_file_type_definitions() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic # save_file_type_definitions(data: dict) - Replaced by Configuration.save_file_type_definitions() # load_supplier_settings() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic # save_supplier_settings(data: dict) - Replaced by Configuration.save_supplier_settings()