From eaedf3268d4bf8d70cb3db86b278b1d4f204b7d0 Mon Sep 17 00:00:00 2001 From: Benexl Date: Mon, 18 Aug 2025 14:06:31 +0300 Subject: [PATCH] feat(config): switch to toml format --- viu_media/assets/defaults/ascii-art | 1 - viu_media/cli/commands/config.py | 16 ++--- viu_media/cli/config/__init__.py | 4 +- viu_media/cli/config/generate.py | 91 ++++++++++++++++++++--------- viu_media/cli/config/loader.py | 57 +++++++++--------- viu_media/core/config/model.py | 34 +++-------- viu_media/core/constants.py | 2 +- 7 files changed, 109 insertions(+), 96 deletions(-) diff --git a/viu_media/assets/defaults/ascii-art b/viu_media/assets/defaults/ascii-art index 575824c..766ecec 100644 --- a/viu_media/assets/defaults/ascii-art +++ b/viu_media/assets/defaults/ascii-art @@ -1,4 +1,3 @@ - ██╗░░░██╗██╗██╗░░░██╗ ██║░░░██║██║██║░░░██║ ╚██╗░██╔╝██║██║░░░██║ diff --git a/viu_media/cli/commands/config.py b/viu_media/cli/commands/config.py index 1935fc1..8be3a30 100644 --- a/viu_media/cli/commands/config.py +++ b/viu_media/cli/commands/config.py @@ -72,7 +72,7 @@ def config( ): from ...core.constants import USER_CONFIG from ..config.editor import InteractiveConfigEditor - from ..config.generate import generate_config_ini_from_app_model + from ..config.generate import generate_config_toml_from_app_model if path: print(USER_CONFIG) @@ -81,9 +81,9 @@ def config( from rich.syntax import Syntax console = Console() - config_ini = generate_config_ini_from_app_model(user_config) + config_toml = generate_config_toml_from_app_model(user_config) syntax = Syntax( - config_ini, + config_toml, "ini", theme=user_config.general.pygment_style, line_numbers=True, @@ -99,12 +99,14 @@ def config( elif interactive: editor = InteractiveConfigEditor(current_config=user_config) new_config = editor.run() - with open(USER_CONFIG, "w", encoding="utf-8") as file: - file.write(generate_config_ini_from_app_model(new_config)) + USER_CONFIG.write_text( + generate_config_toml_from_app_model(new_config), encoding="utf-8" + ) click.echo(f"Configuration saved successfully to {USER_CONFIG}") elif update: - with open(USER_CONFIG, "w", encoding="utf-8") as file: - file.write(generate_config_ini_from_app_model(user_config)) + USER_CONFIG.write_text( + generate_config_toml_from_app_model(user_config), encoding="utf-8" + ) print("update successfull") else: click.edit(filename=str(USER_CONFIG)) diff --git a/viu_media/cli/config/__init__.py b/viu_media/cli/config/__init__.py index ac60aec..b8cb807 100644 --- a/viu_media/cli/config/__init__.py +++ b/viu_media/cli/config/__init__.py @@ -1,4 +1,4 @@ -from .generate import generate_config_ini_from_app_model +from .generate import generate_config_toml_from_app_model from .loader import ConfigLoader -__all__ = ["ConfigLoader", "generate_config_ini_from_app_model"] +__all__ = ["ConfigLoader", "generate_config_toml_from_app_model"] diff --git a/viu_media/cli/config/generate.py b/viu_media/cli/config/generate.py index 58dc07b..d0a3546 100644 --- a/viu_media/cli/config/generate.py +++ b/viu_media/cli/config/generate.py @@ -1,4 +1,6 @@ +# viu_media/cli/config/generate.py import itertools +import json import textwrap from enum import Enum from pathlib import Path @@ -20,9 +22,11 @@ CONFIG_HEADER = f""" {config_asci} # # ============================================================================== -# This file was auto-generated from the application's configuration model. +# This is the Viu configuration file. It uses the TOML format. # You can modify these values to customize the behavior of Viu. -# For path-based options, you can use '~' for your home directory. +# For more information on the available options, please refer to the +# official documentation on GitHub. +# ============================================================================== """.lstrip() CONFIG_FOOTER = f""" @@ -39,21 +43,22 @@ CONFIG_FOOTER = f""" """.lstrip() -def generate_config_ini_from_app_model(app_model: AppConfig) -> str: - """Generate a configuration file content from a Pydantic model.""" +def generate_config_toml_from_app_model(app_model: AppConfig) -> str: + """Generate a TOML configuration file content from a Pydantic model with comments.""" - config_ini_content = [CONFIG_HEADER] + config_content_parts = [CONFIG_HEADER] for section_name, section_model in app_model: - section_comment = section_model.model_config.get("title", "") + section_title = section_model.model_config.get("title", section_name.title()) - config_ini_content.append(f"\n#\n# {section_comment}\n#") - config_ini_content.append(f"[{section_name}]") + config_content_parts.append(f"\n#\n# {section_title}\n#") + config_content_parts.append(f"[{section_name}]") for field_name, field_info in itertools.chain( section_model.model_fields.items(), section_model.model_computed_fields.items(), ): + # --- Generate Comments --- description = field_info.description or "" if description: wrapped_comment = textwrap.fill( @@ -62,7 +67,7 @@ def generate_config_ini_from_app_model(app_model: AppConfig) -> str: initial_indent="# ", subsequent_indent="# ", ) - config_ini_content.append(f"\n{wrapped_comment}") + config_content_parts.append(f"\n{wrapped_comment}") field_type_comment = _get_field_type_comment(field_info) if field_type_comment: @@ -72,35 +77,65 @@ def generate_config_ini_from_app_model(app_model: AppConfig) -> str: initial_indent="# ", subsequent_indent="# ", ) - config_ini_content.append(wrapped_comment) + config_content_parts.append(wrapped_comment) + if ( hasattr(field_info, "default") - and field_info.default != PydanticUndefined + and field_info.default is not PydanticUndefined ): + default_val = ( + field_info.default.value + if isinstance(field_info.default, Enum) + else field_info.default + ) wrapped_comment = textwrap.fill( - f"Default: {field_info.default.value if isinstance(field_info.default, Enum) else field_info.default}", + f"Default: {_format_toml_value(default_val)}", width=78, initial_indent="# ", subsequent_indent="# ", ) - config_ini_content.append(wrapped_comment) + config_content_parts.append(wrapped_comment) + # --- Generate Key-Value Pair --- field_value = getattr(section_model, field_name) - if isinstance(field_value, bool): - value_str = str(field_value).lower() - elif isinstance(field_value, Path): - value_str = str(field_value) - elif field_value is None: - value_str = "" - elif isinstance(field_value, Enum): - value_str = field_value.value + + if field_value is None: + config_content_parts.append(f"# {field_name} =") else: - value_str = str(field_value) + value_str = _format_toml_value(field_value) + config_content_parts.append(f"{field_name} = {value_str}") - config_ini_content.append(f"{field_name} = {value_str}") + config_content_parts.extend(["\n", CONFIG_FOOTER]) + return "\n".join(config_content_parts) - config_ini_content.extend(["\n", CONFIG_FOOTER]) - return "\n".join(config_ini_content) + +def _format_toml_value(value: Any) -> str: + """ + Manually formats a Python value into a TOML-compliant string. + This avoids needing an external TOML writer dependency. + """ + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, Enum): + return f'"{value.value}"' + + # Handle strings and Paths, differentiating between single and multi-line + if isinstance(value, (str, Path)): + str_val = str(value) + if "\n" in str_val: + # For multi-line strings, use triple quotes. + # Also, escape any triple quotes that might be in the string itself. + escaped_val = str_val.replace('"""', '\\"\\"\\"') + return f'"""\n{escaped_val}"""' + else: + # For single-line strings, use double quotes and escape relevant characters. + escaped_val = str_val.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped_val}"' + + # Fallback for any other types + return f'"{str(value)}"' def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str: @@ -111,7 +146,6 @@ def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str: else field_info.return_type ) - # Handle Literal and Enum types possible_values = [] if field_type is not None: if isinstance(field_type, type) and issubclass(field_type, Enum): @@ -122,9 +156,8 @@ def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str: possible_values = list(args) if possible_values: - return f"Possible values: [ {', '.join(map(str, possible_values))} ]" - - # Handle basic types and numeric ranges + formatted_values = ", ".join(json.dumps(v) for v in possible_values) + return f"Possible values: [ {formatted_values} ]" type_name = _get_type_name(field_type) range_info = _get_range_info(field_info) diff --git a/viu_media/cli/config/loader.py b/viu_media/cli/config/loader.py index b4e6e3b..81b5bd6 100644 --- a/viu_media/cli/config/loader.py +++ b/viu_media/cli/config/loader.py @@ -1,5 +1,5 @@ -import configparser -import os +import logging +import tomllib from pathlib import Path from typing import Dict @@ -10,12 +10,14 @@ from ...core.config import AppConfig from ...core.constants import USER_CONFIG from ...core.exceptions import ConfigError +logger = logging.getLogger(__name__) + class ConfigLoader: """ - Handles loading the application configuration from an .ini file. + Handles loading the application configuration from a .toml file. - It ensures a default configuration exists, reads the .ini file, + It ensures a default configuration exists, reads the .toml file, and uses Pydantic to parse and validate the data into a type-safe AppConfig object. """ @@ -25,25 +27,19 @@ class ConfigLoader: Initializes the loader with the path to the configuration file. Args: - config_path: The path to the user's config.ini file. + config_path: The path to the user's config.toml file. """ self.config_path = config_path - self.parser = configparser.ConfigParser( - interpolation=configparser.ExtendedInterpolation(), - defaults=os.environ, - allow_no_value=True, - dict_type=dict, - ) def _handle_first_run(self) -> AppConfig: - """Handles the configuration process when no config file is found.""" + """Handles the configuration process when no config.toml file is found.""" click.echo( "[bold yellow]Welcome to Viu![/bold yellow] No configuration file found." ) from InquirerPy import inquirer from .editor import InteractiveConfigEditor - from .generate import generate_config_ini_from_app_model + from .generate import generate_config_toml_from_app_model choice = inquirer.select( # type: ignore message="How would you like to proceed?", @@ -60,16 +56,17 @@ class ConfigLoader: else: app_config = AppConfig() - config_ini_content = generate_config_ini_from_app_model(app_config) + config_toml_content = generate_config_toml_from_app_model(app_config) try: self.config_path.parent.mkdir(parents=True, exist_ok=True) - self.config_path.write_text(config_ini_content, encoding="utf-8") + self.config_path.write_text(config_toml_content, encoding="utf-8") click.echo( f"Configuration file created at: [green]{self.config_path}[/green]" ) except Exception as e: raise ConfigError( - f"Could not create configuration file at {self.config_path!s}. Please check permissions. Error: {e}", + f"Could not create configuration file at {self.config_path!s}. " + f"Please check permissions. Error: {e}", ) return app_config @@ -78,32 +75,34 @@ class ConfigLoader: """ Loads the configuration and returns a populated, validated AppConfig object. + Args: + update: A dictionary of CLI overrides to apply to the loaded config. + Returns: - An instance of AppConfig with values from the user's .ini file. + An instance of AppConfig with values from the user's .toml file. Raises: - click.ClickException: If the configuration file contains validation errors. + ConfigError: If the configuration file contains validation or parsing errors. """ if not self.config_path.exists(): return self._handle_first_run() try: - self.parser.read(self.config_path, encoding="utf-8") - except configparser.Error as e: + with self.config_path.open("rb") as f: + config_dict = tomllib.load(f) + except tomllib.TOMLDecodeError as e: raise ConfigError( f"Error parsing configuration file '{self.config_path}':\n{e}" ) - # Convert the configparser object into a nested dictionary that mirrors - # the structure of our AppConfig Pydantic model. - config_dict = { - section: dict(self.parser.items(section)) - for section in self.parser.sections() - } + # Apply CLI overrides on top of the loaded configuration if update: - for key in config_dict: - if key in update: - config_dict[key].update(update[key]) + for section, values in update.items(): + if section in config_dict: + config_dict[section].update(values) + else: + config_dict[section] = values + try: app_config = AppConfig.model_validate(config_dict) return app_config diff --git a/viu_media/core/config/model.py b/viu_media/core/config/model.py index d2da931..f125b7a 100644 --- a/viu_media/core/config/model.py +++ b/viu_media/core/config/model.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Literal -from pydantic import BaseModel, Field, PrivateAttr, computed_field +from pydantic import BaseModel, Field from ...libs.media_api.types import MediaSort, UserMediaListSort from ...libs.provider.anime.types import ProviderName, ProviderServer @@ -323,14 +323,16 @@ class SessionsConfig(OtherConfig): class FzfConfig(OtherConfig): """Configuration specific to the FZF selector.""" - _opts: str = PrivateAttr( - default_factory=lambda: defaults.FZF_OPTS.read_text(encoding="utf-8") + opts: str = Field( + default_factory=lambda: defaults.FZF_OPTS.read_text(encoding="utf-8"), + description=desc.FZF_OPTS, ) header_color: str = Field( default=defaults.FZF_HEADER_COLOR, description=desc.FZF_HEADER_COLOR ) - _header_ascii_art: str = PrivateAttr( - default_factory=lambda: APP_ASCII_ART.read_text(encoding="utf-8") + header_ascii_art: str = Field( + default_factory=lambda: APP_ASCII_ART.read_text(encoding="utf-8"), + description=desc.FZF_HEADER_ASCII_ART, ) preview_header_color: str = Field( default=defaults.FZF_PREVIEW_HEADER_COLOR, @@ -341,28 +343,6 @@ class FzfConfig(OtherConfig): description=desc.FZF_PREVIEW_SEPARATOR_COLOR, ) - def __init__(self, **kwargs): - opts = kwargs.pop("opts", None) - header_ascii_art = kwargs.pop("header_ascii_art", None) - - super().__init__(**kwargs) - if opts: - self._opts = opts - if header_ascii_art: - self._header_ascii_art = header_ascii_art - - @computed_field(description=desc.FZF_OPTS) - @property - def opts(self) -> str: - return "\n" + "\n".join([f"\t{line}" for line in self._opts.split()]) - - @computed_field(description=desc.FZF_HEADER_ASCII_ART) - @property - def header_ascii_art(self) -> str: - return "\n" + "\n".join( - [f"\t{line}" for line in self._header_ascii_art.split()] - ) - class RofiConfig(OtherConfig): """Configuration specific to the Rofi selector.""" diff --git a/viu_media/core/constants.py b/viu_media/core/constants.py index 169a84f..008921a 100644 --- a/viu_media/core/constants.py +++ b/viu_media/core/constants.py @@ -82,6 +82,6 @@ APP_CACHE_DIR.mkdir(parents=True, exist_ok=True) LOG_FOLDER.mkdir(parents=True, exist_ok=True) USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True) -USER_CONFIG = APP_DATA_DIR / "config.ini" +USER_CONFIG = APP_DATA_DIR / "config.toml" LOG_FILE = LOG_FOLDER / "app.log"