mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-02-04 11:07:48 -08:00
feat(config): switch to toml format
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
|
||||
██╗░░░██╗██╗██╗░░░██╗
|
||||
██║░░░██║██║██║░░░██║
|
||||
╚██╗░██╔╝██║██║░░░██║
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user