feat(config): switch to toml format

This commit is contained in:
Benexl
2025-08-18 14:06:31 +03:00
parent ade0465ea4
commit eaedf3268d
7 changed files with 109 additions and 96 deletions

View File

@@ -1,4 +1,3 @@
██╗░░░██╗██╗██╗░░░██╗
██║░░░██║██║██║░░░██║
╚██╗░██╔╝██║██║░░░██║

View File

@@ -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))

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"