462 lines
22 KiB
Python
462 lines
22 KiB
Python
# configuration.py
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import logging
|
|
import re # Import the regex module
|
|
import json # Import the json module
|
|
|
|
log = logging.getLogger(__name__) # Use logger defined in main.py
|
|
|
|
# --- Constants ---
|
|
# Assumes config/ and presets/ are relative to this file's location
|
|
BASE_DIR = Path(__file__).parent
|
|
APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json"
|
|
PRESETS_DIR = BASE_DIR / "presets"
|
|
|
|
# --- Custom Exception ---
|
|
class ConfigurationError(Exception):
|
|
"""Custom exception for configuration loading errors."""
|
|
pass
|
|
|
|
# --- Helper Functions ---
|
|
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
|
|
|
|
|
|
# --- Configuration Class ---
|
|
class Configuration:
|
|
"""
|
|
Loads and provides access to core settings combined with a specific preset.
|
|
"""
|
|
def __init__(self, preset_name: str):
|
|
"""
|
|
Loads core config and the specified preset file.
|
|
|
|
Args:
|
|
preset_name: The name of the preset (without .json extension).
|
|
|
|
Raises:
|
|
ConfigurationError: If core config or preset cannot be loaded/validated.
|
|
"""
|
|
log.debug(f"Initializing Configuration with preset: '{preset_name}'")
|
|
self.preset_name = preset_name
|
|
self._core_settings: dict = self._load_core_config()
|
|
self._preset_settings: dict = self._load_preset(preset_name)
|
|
self._validate_configs()
|
|
self._compile_regex_patterns() # Compile regex after validation
|
|
log.info(f"Configuration loaded successfully using preset: '{self.preset_name}'")
|
|
|
|
|
|
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)
|
|
self.compiled_map_keyword_regex: dict[str, list[tuple[re.Pattern, str, int]]] = {}
|
|
# Store the original rule order for priority checking later if needed (can be removed if index is stored in tuple)
|
|
# self._map_type_rule_order: list[dict] = [] # Keep for now, might be useful elsewhere
|
|
|
|
# Compile Extra Patterns (case-insensitive)
|
|
for pattern in self.move_to_extra_patterns:
|
|
try:
|
|
# Use the raw fnmatch pattern directly if it's simple enough for re.search
|
|
# Or convert using helper if needed. Let's try direct search first.
|
|
# We want to find the pattern *within* the filename.
|
|
regex_str = _fnmatch_to_regex(pattern) # Convert wildcards
|
|
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.")
|
|
|
|
# Compile Model Patterns (case-insensitive)
|
|
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.")
|
|
|
|
# Compile Bit Depth Variant Patterns (case-sensitive recommended)
|
|
for map_type, pattern in self.source_bit_depth_variants.items():
|
|
try:
|
|
# These often rely on specific suffixes, so anchoring might be better?
|
|
# Let's stick to the converted pattern for now, assuming it ends with suffix.
|
|
regex_str = _fnmatch_to_regex(pattern) # e.g., ".*_DISP16.*"
|
|
# If the original pattern ended with *, remove the trailing '.*' for suffix matching
|
|
if pattern.endswith('*'):
|
|
regex_str = regex_str.removesuffix('.*') # e.g., ".*_DISP16"
|
|
# Fallback for < 3.9: if regex_str.endswith('.*'): regex_str = regex_str[:-2]
|
|
|
|
# Use the fnmatch-converted regex directly, allowing matches anywhere in the filename
|
|
# This is less strict than anchoring to the end with \\.[^.]+$
|
|
final_regex_str = regex_str # Use the result from _fnmatch_to_regex
|
|
self.compiled_bit_depth_regex_map[map_type] = re.compile(final_regex_str, re.IGNORECASE) # Added 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.")
|
|
|
|
# Compile Map Type Keywords (case-insensitive) based on the new structure
|
|
separator = re.escape(self.source_naming_separator) # Escape separator for regex
|
|
# Use defaultdict to easily append to lists for the same base type
|
|
from collections import defaultdict
|
|
temp_compiled_map_regex = defaultdict(list)
|
|
|
|
for rule_index, mapping_rule in enumerate(self.map_type_mapping):
|
|
# Validate rule structure (dictionary with target_type and keywords)
|
|
if not isinstance(mapping_rule, dict) or \
|
|
'target_type' not in mapping_rule or \
|
|
'keywords' not in mapping_rule or \
|
|
not isinstance(mapping_rule['keywords'], list):
|
|
log.warning(f"Skipping invalid map_type_mapping rule at index {rule_index}: {mapping_rule}. Expected dict with 'target_type' and 'keywords' list.")
|
|
continue
|
|
|
|
target_type = mapping_rule['target_type'].upper() # Use the base type directly
|
|
source_keywords = mapping_rule['keywords']
|
|
|
|
# Store the rule for potential priority access later (optional, index is now in tuple)
|
|
# self._map_type_rule_order.append(mapping_rule)
|
|
|
|
if target_type not in self.standard_map_types:
|
|
log.warning(f"Map rule {rule_index} uses target_type '{target_type}' which is not in core config STANDARD_MAP_TYPES. Classification might be incomplete.")
|
|
# Continue processing anyway, as it might be intended for merging etc.
|
|
|
|
# Compile keywords for this rule and store with context
|
|
for keyword in source_keywords:
|
|
if not isinstance(keyword, str):
|
|
log.warning(f"Skipping non-string keyword '{keyword}' in rule {rule_index} for target '{target_type}'.")
|
|
continue
|
|
try:
|
|
# Match keyword potentially surrounded by separators or start/end
|
|
# Handle potential wildcards within the keyword using fnmatch conversion
|
|
kw_regex_part = _fnmatch_to_regex(keyword)
|
|
# Build regex to match the keyword part, anchored by separators or string boundaries
|
|
# Use non-capturing groups (?:...)
|
|
# Capture the keyword part itself for potential use later if needed (group 1)
|
|
regex_str = rf"(?:^|{separator})({kw_regex_part})(?:$|{separator})"
|
|
compiled_regex = re.compile(regex_str, re.IGNORECASE)
|
|
# Append tuple: (compiled_regex, original_keyword, rule_index)
|
|
temp_compiled_map_regex[target_type].append((compiled_regex, keyword, rule_index))
|
|
log.debug(f" Compiled keyword '{keyword}' (rule {rule_index}) for target '{target_type}' as regex: {regex_str}")
|
|
except re.error as e:
|
|
log.warning(f"Failed to compile map keyword regex '{keyword}' for target type '{target_type}': {e}. Skipping keyword.")
|
|
|
|
# Assign the compiled regex dictionary
|
|
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 _load_core_config(self) -> dict:
|
|
"""Loads settings from the core app_settings.json file."""
|
|
log.debug(f"Loading core config from: {APP_SETTINGS_PATH}")
|
|
if not APP_SETTINGS_PATH.is_file():
|
|
raise ConfigurationError(f"Core configuration file not found: {APP_SETTINGS_PATH}")
|
|
try:
|
|
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
|
settings = json.load(f)
|
|
log.debug(f"Core config loaded successfully.")
|
|
return settings
|
|
except json.JSONDecodeError as e:
|
|
raise ConfigurationError(f"Failed to parse core configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}")
|
|
except Exception as e:
|
|
raise ConfigurationError(f"Failed to read core configuration file {APP_SETTINGS_PATH}: {e}")
|
|
|
|
def _load_preset(self, preset_name: str) -> dict:
|
|
"""Loads the specified preset JSON file."""
|
|
log.debug(f"Loading preset: '{preset_name}' from {PRESETS_DIR}")
|
|
if not PRESETS_DIR.is_dir():
|
|
raise ConfigurationError(f"Presets directory not found: {PRESETS_DIR}")
|
|
|
|
preset_file = PRESETS_DIR / f"{preset_name}.json"
|
|
if not preset_file.is_file():
|
|
raise ConfigurationError(f"Preset file not found: {preset_file}")
|
|
|
|
try:
|
|
with open(preset_file, 'r', encoding='utf-8') as f:
|
|
preset_data = json.load(f)
|
|
log.debug(f"Preset '{preset_name}' loaded successfully.")
|
|
return preset_data
|
|
except json.JSONDecodeError as e:
|
|
raise ConfigurationError(f"Failed to parse preset file {preset_file}: Invalid JSON - {e}")
|
|
except Exception as e:
|
|
raise ConfigurationError(f"Failed to read preset file {preset_file}: {e}")
|
|
|
|
def _validate_configs(self):
|
|
"""Performs basic validation checks on loaded settings."""
|
|
log.debug("Validating loaded configurations...")
|
|
# 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 '{self.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 '{self.preset_name}': '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 '{self.preset_name}': 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 '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'target_type' string.")
|
|
if 'keywords' not in rule or not isinstance(rule['keywords'], list):
|
|
raise ConfigurationError(f"Preset '{self.preset_name}': Rule at index {index} in 'map_type_mapping' is missing 'keywords' list.")
|
|
for kw_index, keyword in enumerate(rule['keywords']):
|
|
if not isinstance(keyword, str):
|
|
raise ConfigurationError(f"Preset '{self.preset_name}': Keyword at index {kw_index} in rule {index} ('{rule['target_type']}') must be a string.")
|
|
|
|
|
|
# Core validation (check types or specific values if needed)
|
|
if not isinstance(self._core_settings.get('TARGET_FILENAME_PATTERN'), str):
|
|
raise ConfigurationError("Core config 'TARGET_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.")
|
|
if not isinstance(self._core_settings.get('STANDARD_MAP_TYPES'), list):
|
|
raise ConfigurationError("Core config 'STANDARD_MAP_TYPES' must be a list.")
|
|
# Add more checks as necessary
|
|
log.debug("Configuration validation passed.")
|
|
|
|
|
|
# --- Accessor Methods/Properties ---
|
|
# Use @property for direct access, methods for potentially complex lookups/defaults
|
|
|
|
@property
|
|
def supplier_name(self) -> str:
|
|
return self._preset_settings.get('supplier_name', 'DefaultSupplier')
|
|
|
|
@property
|
|
def default_asset_category(self) -> str:
|
|
"""Gets the default asset category from core settings."""
|
|
# Provide a fallback default just in case it's missing from config.py
|
|
return self._core_settings.get('DEFAULT_ASSET_CATEGORY', 'Texture')
|
|
|
|
@property
|
|
def target_filename_pattern(self) -> str:
|
|
return self._core_settings['TARGET_FILENAME_PATTERN'] # Assumes validation passed
|
|
|
|
@property
|
|
def image_resolutions(self) -> dict[str, int]:
|
|
return self._core_settings['IMAGE_RESOLUTIONS']
|
|
|
|
@property
|
|
def standard_map_types(self) -> list[str]:
|
|
return self._core_settings['STANDARD_MAP_TYPES']
|
|
|
|
@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) # Use default if somehow missing
|
|
|
|
@property
|
|
def resolution_threshold_for_jpg(self) -> int:
|
|
"""Gets the pixel dimension threshold for using JPG for 8-bit images."""
|
|
return self._core_settings.get('RESOLUTION_THRESHOLD_FOR_JPG', 4096)
|
|
|
|
@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_rule(self, map_type: str) -> str:
|
|
"""Gets the bit depth rule ('respect' or 'force_8bit') for a given standard map type."""
|
|
# Access the FILE_TYPE_DEFINITIONS from core settings
|
|
file_type_definitions = self._core_settings.get('FILE_TYPE_DEFINITIONS', {})
|
|
|
|
# Iterate through definitions to find the matching map type
|
|
for definition in file_type_definitions.values():
|
|
if definition.get('standard_type') == map_type:
|
|
# Found the definition, check for 'bit_depth_rule'
|
|
return definition.get('bit_depth_rule', 'respect')
|
|
|
|
# If map type definition not found, return default rule
|
|
return 'respect'
|
|
|
|
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()
|
|
|
|
# --- LLM Prompt Data Accessors ---
|
|
|
|
def get_asset_type_definitions(self) -> dict:
|
|
"""Returns the ASSET_TYPE_DEFINITIONS dictionary from core settings."""
|
|
return self._core_settings.get('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) from core settings."""
|
|
return self._core_settings.get('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 core settings."""
|
|
return self._core_settings.get('llm_predictor_examples', [])
|
|
|
|
def get_setting(self, key: str, default: any = None) -> any:
|
|
"""Gets a specific setting by key from the core settings."""
|
|
# Note: This accesses _core_settings directly, not combined/preset settings.
|
|
return self._core_settings.get(key, default)
|
|
# --- Standalone Base Config Functions ---
|
|
|
|
def load_base_config() -> dict:
|
|
"""
|
|
Loads only the base configuration from app_settings.json.
|
|
Does not load presets or perform merging/validation.
|
|
"""
|
|
#log.debug(f"Loading base config from: {APP_SETTINGS_PATH}")
|
|
if not APP_SETTINGS_PATH.is_file():
|
|
log.error(f"Base configuration file not found: {APP_SETTINGS_PATH}")
|
|
# Return empty dict or raise a specific error if preferred
|
|
# For now, return empty dict to allow GUI to potentially start with defaults
|
|
return {}
|
|
try:
|
|
with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f:
|
|
settings = json.load(f)
|
|
#log.debug(f"Base config loaded successfully.")
|
|
return settings
|
|
except json.JSONDecodeError as e:
|
|
log.error(f"Failed to parse base configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}")
|
|
return {} # Return empty dict on error
|
|
except Exception as e:
|
|
log.error(f"Failed to read base configuration file {APP_SETTINGS_PATH}: {e}")
|
|
return {} # Return empty dict on error
|
|
|
|
def save_base_config(settings_dict: dict):
|
|
"""
|
|
Saves the provided settings dictionary to app_settings.json.
|
|
"""
|
|
log.debug(f"Saving base config to: {APP_SETTINGS_PATH}")
|
|
try:
|
|
with open(APP_SETTINGS_PATH, 'w', encoding='utf-8') as f:
|
|
json.dump(settings_dict, f, indent=4)
|
|
log.debug(f"Base config saved successfully.")
|
|
except Exception as e:
|
|
log.error(f"Failed to save base configuration file {APP_SETTINGS_PATH}: {e}")
|
|
raise ConfigurationError(f"Failed to save configuration: {e}")
|