You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
RedditVideoMakerBot/utils/settings.py

306 lines
14 KiB

import re
import json # Added import
from pathlib import Path
from typing import Dict, Tuple, Callable, Any
import toml
import logging # Added for logging
from rich.console import Console # Keep for rich formatting in handle_input if needed, but prefer logging for app messages
from utils.console import handle_input # handle_input uses console.print, will need review
# console = Console() # Replaced by logger for general messages
logger = logging.getLogger(__name__)
config = dict # autocomplete
# --- Helper for safe type conversion ---
def _safe_str_to_bool(val: Any) -> bool:
"""Converts a string to boolean in a case-insensitive way."""
if isinstance(val, bool):
return val
val_str = str(val).lower()
if val_str in ("true", "yes", "1", "on"):
return True
if val_str in ("false", "no", "0", "off"):
return False
raise ValueError(f"Cannot convert '{val}' to boolean.")
_TYPE_CONVERTERS: Dict[str, Callable[[Any], Any]] = {
"str": str,
"int": int,
"float": float,
"bool": _safe_str_to_bool,
# Add other types here if needed, e.g., list, dict, but they might require more complex parsing
# For now, assuming basic types are used in the config template's "type" field.
}
def _get_safe_type_converter(type_str: str) -> Callable[[Any], Any]:
"""Returns a safe type conversion function based on a type string."""
converter = _TYPE_CONVERTERS.get(type_str)
if converter is None:
# Fallback or raise error if type_str is not supported
# For safety, let's raise an error if an unknown type string is provided.
raise ValueError(f"Unsupported type string for conversion: {type_str}. Supported types: {list(_TYPE_CONVERTERS.keys())}")
return converter
# --- End of helper ---
def crawl(obj: dict, func=lambda x, y: print(x, y, end="\n"), path=None):
if path is None: # path Default argument value is mutable
path = []
for key in obj.keys():
if type(obj[key]) is dict:
crawl(obj[key], func, path + [key])
continue
func(path + [key], obj[key])
def check(value, checks, name):
def get_check_value(key, default_result):
return checks[key] if key in checks else default_result
incorrect = False
original_value = value # Keep original value for re-input if conversion fails
if value == {}: # Treat empty dict as incorrect for a setting expecting a value
incorrect = True
if not incorrect and "type" in checks:
type_str = checks["type"]
try:
converter = _get_safe_type_converter(type_str)
value = converter(value)
except (ValueError, TypeError) as e: # Catch conversion errors
logger.warning(f"Could not convert value '{original_value}' for '{name}' to type '{type_str}'. Error: {e}")
incorrect = True
except Exception as e: # Catch any other unexpected errors during conversion
logger.error(f"Unexpected error converting value for '{name}' to type '{type_str}'. Error: {e}", exc_info=True)
incorrect = True
# Dynamic options loading for background_choice
current_options = checks.get("options")
if name == "background_choice" and "options" in checks:
try:
with open(Path(__file__).parent / "backgrounds.json", "r", encoding="utf-8") as f:
background_data = json.load(f)
current_options = list(background_data.keys())
if not current_options:
logger.warning("No backgrounds found in backgrounds.json. Using fallback options if available from template.")
current_options = checks.get("options", ["DEFAULT_BACKGROUND_FALLBACK"])
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.warning(f"Could not load backgrounds from backgrounds.json: {e}. Using template options if available.")
current_options = checks.get("options", ["DEFAULT_BACKGROUND_FALLBACK"])
if (
not incorrect and current_options is not None and value not in current_options
): # FAILSTATE Value is not one of the options
incorrect = True
elif ( # Original check if not background_choice or if current_options remained None (should not happen with fallbacks)
not incorrect and name != "background_choice" and "options" in checks and value not in checks["options"]
):
incorrect = True
if (
not incorrect
and "regex" in checks
and (
(isinstance(value, str) and re.match(checks["regex"], value) is None)
or not isinstance(value, str) # Ensure value is string if regex is present
)
): # FAILSTATE Value doesn't match regex, or has regex but is not a string.
incorrect = True
# Length/Value checks for non-iterables (int, float)
if (
not incorrect
and not hasattr(value, "__iter__") # Ensure it's not a string or list here
and not isinstance(value, str) # Explicitly exclude strings from this numeric check
and (
("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"])
or ("nmax" in checks and checks["nmax"] is not None and value > checks["nmax"])
)
):
incorrect = True
# Length checks for iterables (str, list)
if (
not incorrect
and hasattr(value, "__iter__") # Applies to strings, lists, etc.
and (
("nmin" in checks and checks["nmin"] is not None and len(value) < checks["nmin"])
or ("nmax" in checks and checks["nmax"] is not None and len(value) > checks["nmax"])
)
):
incorrect = True
if incorrect:
# Get the type converter for handle_input
# If get_check_value("type", False) was intended to pass the type string itself,
# then we might not need _get_safe_type_converter here, but handle_input needs to be aware.
# Assuming handle_input expects a callable type constructor or our safe converter.
input_type_str = get_check_value("type", None)
input_type_callable = None
if input_type_str:
try:
input_type_callable = _get_safe_type_converter(input_type_str)
except ValueError as e:
logger.warning(f"Invalid type '{input_type_str}' in template for '{name}': {e}. Defaulting to string input for prompt.")
input_type_callable = str
else:
logger.debug(f"No type specified in template for '{name}'. Defaulting to string input for prompt.")
input_type_callable = str
value = handle_input(
message=(
(("[blue]Example: " + str(checks["example"]) + "\n") if "example" in checks else "")
+ "[red]"
+ ("Non-optional ", "Optional ")["optional" in checks and checks["optional"] is True]
)
+ "[#C0CAF5 bold]"
+ str(name)
+ "[#F7768E bold]=",
extra_info=get_check_value("explanation", ""),
check_type=input_type_callable, # Pass the callable converter
default=get_check_value("default", NotImplemented),
match=get_check_value("regex", ""),
err_message=get_check_value("input_error", "Incorrect input"),
nmin=get_check_value("nmin", None),
nmax=get_check_value("nmax", None),
oob_error=get_check_value(
"oob_error", "Input out of bounds(Value too high/low/long/short)"
),
options=get_check_value("options", None),
optional=get_check_value("optional", False),
)
return value
def crawl_and_check(obj: dict, path: list, checks: dict = {}, name=""):
if len(path) == 0:
return check(obj, checks, name)
if path[0] not in obj.keys():
obj[path[0]] = {}
obj[path[0]] = crawl_and_check(obj[path[0]], path[1:], checks, path[0])
return obj
def check_vars(path, checks):
global config
crawl_and_check(config, path, checks)
def check_toml(template_file, config_file) -> Tuple[bool, Dict]:
global config
config = None
try:
template = toml.load(template_file)
logger.debug(f"Successfully loaded template file: {template_file}")
except Exception as error:
logger.error(f"Encountered error when trying to load template file {template_file}: {error}", exc_info=True)
return False
try:
config = toml.load(config_file)
logger.debug(f"Successfully loaded config file: {config_file}")
except toml.TomlDecodeError as e:
logger.error(f"Couldn't decode TOML from {config_file}: {e}")
# Rich print for interactive part, then log the choice
console = Console() # Local console for this interactive part
console.print(f"""[blue]Malformed configuration file detected at {config_file}.
It might be corrupted.
Overwrite with a fresh configuration based on the template? (y/n)[/blue]""")
choice = input().strip().lower()
logger.info(f"User choice for overwriting malformed config {config_file}: {choice}")
if not choice.startswith("y"):
logger.warning(f"User chose not to overwrite malformed config {config_file}. Cannot proceed.")
return False
else:
try:
with open(config_file, "w", encoding="utf-8") as f:
f.write("") # Create an empty file to be populated by template
config = {} # Start with an empty config dict
logger.info(f"Malformed config {config_file} cleared for fresh population.")
except IOError as ioe:
logger.error(f"Failed to clear/overwrite malformed config file {config_file}: {ioe}", exc_info=True)
return False
except FileNotFoundError:
logger.info(f"Config file {config_file} not found. Creating it now based on template.")
try:
# Create the file by opening in 'w' mode, then it will be populated by toml.dump later
# No need to write "" explicitly if we are going to dump template content or an empty dict.
# For safety, ensure parent directory exists if config_file includes directories.
Path(config_file).parent.mkdir(parents=True, exist_ok=True)
with open(config_file, "w", encoding="utf-8") as f:
# Start with an empty config, to be filled by crawling the template
toml.dump({}, f)
config = {}
logger.info(f"New config file {config_file} created.")
except IOError as e:
logger.error(f"Failed to create new config file {config_file}: {e}", exc_info=True)
return False
logger.info(
"Checking TOML configuration. User will be prompted for any missing/invalid essential values."
)
# The following banner is fine with print as it's a one-time display for interactive setup.
+ # However, for consistency, it could also be logged at INFO level if desired.
+ # For now, let's keep it as console.print for its specific formatting.
+ # If RichHandler is active for logging, logger.info would also use Rich.
+ # To ensure it uses the local `console` for this specific print:
+ local_console_for_banner = Console()
+ local_console_for_banner.print(
+ """\
+[blue bold]###############################
# #
# Checking TOML configuration #
# #
###############################
If you see any prompts, that means that you have unset/incorrectly set variables, please input the correct values.\
"""
)
crawl(template, check_vars) # Populates global `config` by validating against template
# --- Custom validation for Gemini settings ---
if "gemini" in config and isinstance(config["gemini"], dict):
gemini_settings = config["gemini"]
if gemini_settings.get("enable_summary") is True:
if not gemini_settings.get("api_key"):
logger.warning("Gemini summary is enabled, but API key is missing.")
# Prompt user for API key if missing and summary is enabled
# This uses the `handle_input` which is part of `utils.console`
# and uses Rich Console for prompting.
gemini_api_key_checks = template.get("gemini", {}).get("api_key", {})
# Ensure the 'type' is 'str' for handle_input if not otherwise specified for this direct call
gemini_api_key_checks.setdefault("type", "str")
# We need a local Rich Console instance if handle_input relies on a global one not set here.
# However, handle_input itself creates a Console instance.
api_key_value = handle_input(
message="[#C0CAF5 bold]Gemini API Key ([red]required as summary is enabled[/#C0CAF5 bold]): ",
extra_info=gemini_api_key_checks.get("explanation", "Enter your Google Gemini API Key."),
check_type=_get_safe_type_converter(gemini_api_key_checks.get("type", "str")), # Pass callable
optional=False, # It's not optional if enable_summary is true
err_message="API Key cannot be empty when summary is enabled."
# Potentially add nmin for basic validation if desired
)
config["gemini"]["api_key"] = api_key_value
if not api_key_value: # Double check if user somehow bypassed
logger.error("Gemini API Key is required when enable_summary is true. Configuration incomplete.")
return False # Indicate failure
else:
logger.debug("Gemini summary enabled and API key is present.")
# --- End custom validation ---
with open(config_file, "w", encoding="utf-8") as f: # Ensure encoding
toml.dump(config, f)
logger.info(f"Configuration successfully checked and saved to {config_file}")
return config
if __name__ == "__main__":
directory = Path().absolute()
check_toml(f"{directory}/utils/.config.template.toml", "config.toml")