mirror of
https://github.com/Benexl/FastAnime.git
synced 2026-01-20 08:30:42 -08:00
feat: new config logic
This commit is contained in:
12
fastanime/assets/defaults/fzf-opts
Normal file
12
fastanime/assets/defaults/fzf-opts
Normal file
@@ -0,0 +1,12 @@
|
||||
--color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626
|
||||
--color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00
|
||||
--color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf
|
||||
--color=border:#262626,label:#aeaeae,query:#d9d9d9
|
||||
--border=rounded
|
||||
--border-label=''
|
||||
--preview-window=border-rounded
|
||||
--prompt='>'
|
||||
--marker='>'
|
||||
--pointer='◆'
|
||||
--separator='─'
|
||||
--scrollbar='│'
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
4
fastanime/cli/config/__init__.py
Normal file
4
fastanime/cli/config/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .loader import ConfigLoader
|
||||
from .model import AppConfig
|
||||
|
||||
__all__ = ["ConfigLoader", "AppConfig"]
|
||||
146
fastanime/cli/config/loader.py
Normal file
146
fastanime/cli/config/loader.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import configparser
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ..constants import USER_CONFIG_PATH
|
||||
from .model import AppConfig
|
||||
from ...core.exceptions import ConfigError
|
||||
|
||||
|
||||
from ..constants import ASCII_ART
|
||||
|
||||
|
||||
# The header for the config file.
|
||||
config_asci = "\n".join([f"# {line}" for line in ASCII_ART.split()])
|
||||
CONFIG_HEADER = f"""
|
||||
# ==============================================================================
|
||||
#
|
||||
{config_asci}
|
||||
#
|
||||
# ==============================================================================
|
||||
# This file was auto-generated from the application's configuration model.
|
||||
# You can modify these values to customize the behavior of FastAnime.
|
||||
# For path-based options, you can use '~' for your home directory.
|
||||
""".lstrip()
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""
|
||||
Handles loading the application configuration from an .ini file.
|
||||
|
||||
It ensures a default configuration exists, reads the .ini file,
|
||||
and uses Pydantic to parse and validate the data into a type-safe
|
||||
AppConfig object.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Path = USER_CONFIG_PATH):
|
||||
"""
|
||||
Initializes the loader with the path to the configuration file.
|
||||
|
||||
Args:
|
||||
config_path: The path to the user's config.ini file.
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self.parser = configparser.ConfigParser(
|
||||
interpolation=None,
|
||||
# Allow boolean values without a corresponding value (e.g., `enabled` vs `enabled = true`)
|
||||
allow_no_value=True,
|
||||
# Behave like a dictionary, preserving case sensitivity of keys
|
||||
dict_type=dict,
|
||||
)
|
||||
|
||||
def _create_default_if_not_exists(self) -> None:
|
||||
"""
|
||||
Creates a default config file from the config model if it doesn't exist.
|
||||
This is the only time we write to the user's config directory.
|
||||
"""
|
||||
if not self.config_path.exists():
|
||||
default_config = AppConfig.model_validate({})
|
||||
|
||||
model_schema = AppConfig.model_json_schema()
|
||||
|
||||
config_ini_content = [CONFIG_HEADER]
|
||||
|
||||
for section_name, section_model in default_config:
|
||||
section_class_name = model_schema["properties"][section_name][
|
||||
"$ref"
|
||||
].split("/")[-1]
|
||||
section_comment = model_schema["$defs"][section_class_name][
|
||||
"description"
|
||||
]
|
||||
config_ini_content.append(f"\n#\n# {section_comment}\n#")
|
||||
config_ini_content.append(f"[{section_name}]")
|
||||
|
||||
for field_name, field_value in section_model:
|
||||
description = model_schema["$defs"][section_class_name][
|
||||
"properties"
|
||||
][field_name].get("description", "")
|
||||
|
||||
if description:
|
||||
# Wrap long comments for better readability in the .ini file
|
||||
wrapped_comment = textwrap.fill(
|
||||
description,
|
||||
width=78,
|
||||
initial_indent="# ",
|
||||
subsequent_indent="# ",
|
||||
)
|
||||
config_ini_content.append(f"\n{wrapped_comment}")
|
||||
|
||||
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 = ""
|
||||
else:
|
||||
value_str = str(field_value)
|
||||
|
||||
config_ini_content.append(f"{field_name} = {value_str}")
|
||||
try:
|
||||
final_output = "\n".join(config_ini_content)
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.config_path.write_text(final_output, encoding="utf-8")
|
||||
click.echo(f"Created default configuration file at: {self.config_path}")
|
||||
except Exception as e:
|
||||
raise ConfigError(
|
||||
f"Could not create default configuration file at {str(self.config_path)}. Please check permissions. Error: {e}",
|
||||
)
|
||||
|
||||
def load(self) -> AppConfig:
|
||||
"""
|
||||
Loads the configuration and returns a populated, validated AppConfig object.
|
||||
|
||||
Returns:
|
||||
An instance of AppConfig with values from the user's .ini file.
|
||||
|
||||
Raises:
|
||||
click.ClickException: If the configuration file contains validation errors.
|
||||
"""
|
||||
self._create_default_if_not_exists()
|
||||
|
||||
try:
|
||||
self.parser.read(self.config_path, encoding="utf-8")
|
||||
except configparser.Error 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()
|
||||
}
|
||||
|
||||
try:
|
||||
app_config = AppConfig.model_validate(config_dict)
|
||||
return app_config
|
||||
except ValidationError as e:
|
||||
error_message = (
|
||||
f"Configuration error in '{self.config_path}'!\n"
|
||||
f"Please correct the following issues:\n\n{e}"
|
||||
)
|
||||
raise ConfigError(error_message)
|
||||
152
fastanime/cli/config/model.py
Normal file
152
fastanime/cli/config/model.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Literal
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator, ConfigDict
|
||||
from ..constants import USER_VIDEOS_DIR, ASCII_ART
|
||||
from ...core.constants import (
|
||||
FZF_DEFAULT_OPTS,
|
||||
ROFI_THEME_MAIN,
|
||||
ROFI_THEME_INPUT,
|
||||
ROFI_THEME_CONFIRM,
|
||||
ROFI_THEME_PREVIEW,
|
||||
)
|
||||
from ...libs.anime_provider import SERVERS_AVAILABLE
|
||||
from ...libs.anilist.constants import SORTS_AVAILABLE
|
||||
|
||||
|
||||
class FzfConfig(BaseModel):
|
||||
"""Configuration specific to the FZF selector."""
|
||||
|
||||
opts: str = Field(
|
||||
default_factory=lambda: "\n"
|
||||
+ "\n".join(
|
||||
[
|
||||
f"\t{line}"
|
||||
for line in FZF_DEFAULT_OPTS.read_text(encoding="utf-8").split()
|
||||
]
|
||||
),
|
||||
description="Command-line options to pass to FZF for theming and behavior.",
|
||||
)
|
||||
header_color: str = "95,135,175"
|
||||
preview_header_color: str = "215,0,95"
|
||||
preview_separator_color: str = "208,208,208"
|
||||
|
||||
|
||||
class RofiConfig(BaseModel):
|
||||
"""Configuration specific to the Rofi selector."""
|
||||
|
||||
theme_main: Path = Path(str(ROFI_THEME_MAIN))
|
||||
theme_preview: Path = Path(str(ROFI_THEME_PREVIEW))
|
||||
theme_confirm: Path = Path(str(ROFI_THEME_CONFIRM))
|
||||
theme_input: Path = Path(str(ROFI_THEME_INPUT))
|
||||
|
||||
|
||||
class MpvConfig(BaseModel):
|
||||
"""Configuration specific to the MPV player integration."""
|
||||
|
||||
args: str = Field(
|
||||
default="", description="Comma-separated arguments to pass to the MPV player."
|
||||
)
|
||||
pre_args: str = Field(
|
||||
default="",
|
||||
description="Comma-separated arguments to prepend before the MPV command.",
|
||||
)
|
||||
disable_popen: bool = Field(
|
||||
default=True,
|
||||
description="Disable using subprocess.Popen for MPV, which can be unstable on some systems.",
|
||||
)
|
||||
force_window: str = Field(
|
||||
default="immediate", description="Value for MPV's --force-window option."
|
||||
)
|
||||
use_python_mpv: bool = Field(
|
||||
default=False,
|
||||
description="Use the python-mpv library for enhanced player control.",
|
||||
)
|
||||
|
||||
|
||||
class GeneralConfig(BaseModel):
|
||||
"""Configuration for general application behavior and integrations."""
|
||||
|
||||
provider: Literal["allanime", "animepahe", "hianime", "nyaa", "yugen"] = "allanime"
|
||||
selector: Literal["default", "fzf", "rofi"] = "default"
|
||||
auto_select_anime_result: bool = True
|
||||
|
||||
# UI/UX Settings
|
||||
icons: bool = False
|
||||
preview: Literal["full", "text", "image", "none"] = "none"
|
||||
image_renderer: Literal["icat", "chafa", "imgcat"] = "chafa"
|
||||
preferred_language: Literal["english", "romaji"] = "english"
|
||||
sub_lang: str = "eng"
|
||||
manga_viewer: Literal["feh", "icat"] = "feh"
|
||||
|
||||
# Paths & Files
|
||||
downloads_dir: Path = USER_VIDEOS_DIR
|
||||
|
||||
# Theming & Appearance
|
||||
header_ascii_art: str = Field(
|
||||
default="\n" + "\n".join([f"\t{line}" for line in ASCII_ART.split()]),
|
||||
description="ASCII art for TUI headers.",
|
||||
)
|
||||
|
||||
# Advanced / Developer
|
||||
check_for_updates: bool = True
|
||||
cache_requests: bool = True
|
||||
max_cache_lifetime: str = "03:00:00"
|
||||
normalize_titles: bool = True
|
||||
discord: bool = False
|
||||
|
||||
|
||||
class StreamConfig(BaseModel):
|
||||
"""Configuration specific to video streaming and playback."""
|
||||
|
||||
player: Literal["mpv", "vlc"] = "mpv"
|
||||
quality: Literal["360", "480", "720", "1080"] = "1080"
|
||||
translation_type: Literal["sub", "dub"] = "sub"
|
||||
|
||||
server: str = "top"
|
||||
|
||||
# Playback Behavior
|
||||
auto_next: bool = False
|
||||
continue_from_watch_history: bool = True
|
||||
preferred_watch_history: Literal["local", "remote"] = "local"
|
||||
auto_skip: bool = False
|
||||
episode_complete_at: int = Field(default=80, ge=0, le=100)
|
||||
|
||||
# Technical/Downloader Settings
|
||||
ytdlp_format: str = "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best"
|
||||
|
||||
@field_validator("server")
|
||||
@classmethod
|
||||
def validate_server(cls, v: str) -> str:
|
||||
if v not in SERVERS_AVAILABLE:
|
||||
raise ValidationError(f"server must be one of {SERVERS_AVAILABLE}")
|
||||
return v
|
||||
|
||||
|
||||
class AnilistConfig(BaseModel):
|
||||
"""Configuration for interacting with the AniList API."""
|
||||
|
||||
per_page: int = Field(default=15, gt=0, le=50)
|
||||
sort_by: str = "SEARCH_MATCH"
|
||||
default_media_list_tracking: Literal["track", "disabled", "prompt"] = "prompt"
|
||||
force_forward_tracking: bool = True
|
||||
recent: int = Field(default=50, ge=0)
|
||||
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def validate_sort_by(cls, v: str) -> str:
|
||||
if v not in SORTS_AVAILABLE:
|
||||
raise ValidationError(f"sort_by must be one of {SORTS_AVAILABLE}")
|
||||
return v
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
"""The root configuration model for the FastAnime application."""
|
||||
|
||||
general: GeneralConfig = Field(default_factory=GeneralConfig)
|
||||
stream: StreamConfig = Field(default_factory=StreamConfig)
|
||||
anilist: AnilistConfig = Field(default_factory=AnilistConfig)
|
||||
|
||||
# Nested Tool-Specific Configs
|
||||
fzf: FzfConfig = Field(default_factory=FzfConfig)
|
||||
rofi: RofiConfig = Field(default_factory=RofiConfig)
|
||||
mpv: MpvConfig = Field(default_factory=MpvConfig)
|
||||
50
fastanime/cli/constants.py
Normal file
50
fastanime/cli/constants.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
|
||||
import click
|
||||
|
||||
from ..core.constants import APP_NAME, ICONS_DIR
|
||||
|
||||
ASCII_ART = """
|
||||
|
||||
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
|
||||
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
|
||||
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
|
||||
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
|
||||
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
|
||||
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
|
||||
|
||||
"""
|
||||
PLATFORM = system()
|
||||
USER_NAME = os.environ.get("USERNAME", "Anime Fan")
|
||||
|
||||
|
||||
APP_DATA_DIR = Path(click.get_app_dir(APP_NAME, roaming=False))
|
||||
|
||||
if sys.platform == "win32":
|
||||
APP_CACHE_DIR = APP_DATA_DIR / "cache"
|
||||
USER_VIDEOS_DIR = Path.home() / "Videos" / APP_NAME
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
APP_CACHE_DIR = Path.home() / "Library" / "Caches" / APP_NAME
|
||||
USER_VIDEOS_DIR = Path.home() / "Movies" / APP_NAME
|
||||
|
||||
else:
|
||||
xdg_cache_home = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
||||
APP_CACHE_DIR = xdg_cache_home / APP_NAME
|
||||
|
||||
xdg_videos_dir = Path(os.environ.get("XDG_VIDEOS_DIR", Path.home() / "Videos"))
|
||||
USER_VIDEOS_DIR = xdg_videos_dir / APP_NAME
|
||||
|
||||
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
APP_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
USER_DATA_PATH = APP_DATA_DIR / "user_data.json"
|
||||
USER_WATCH_HISTORY_PATH = APP_DATA_DIR / "watch_history.json"
|
||||
USER_CONFIG_PATH = APP_DATA_DIR / "config.ini"
|
||||
LOG_FILE_PATH = APP_CACHE_DIR / "fastanime.log"
|
||||
|
||||
ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Windows" else "logo.png")
|
||||
@@ -1,86 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
|
||||
import click
|
||||
|
||||
from . import APP_NAME, __version__
|
||||
|
||||
PLATFORM = system()
|
||||
|
||||
# ---- app deps ----
|
||||
APP_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
ASSETS_DIR = os.path.join(APP_DIR, "assets")
|
||||
|
||||
|
||||
# --- icon stuff ---
|
||||
if PLATFORM == "Windows":
|
||||
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
|
||||
else:
|
||||
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
|
||||
# PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
|
||||
|
||||
|
||||
# ----- user configs and data -----
|
||||
|
||||
S_PLATFORM = sys.platform
|
||||
APP_DATA_DIR = click.get_app_dir(APP_NAME, roaming=False)
|
||||
if S_PLATFORM == "win32":
|
||||
# app data
|
||||
# app_data_dir_base = os.getenv("LOCALAPPDATA")
|
||||
# if not app_data_dir_base:
|
||||
# raise RuntimeError("Could not determine app data dir please report to devs")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
|
||||
#
|
||||
# cache dir
|
||||
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.path.join(Path().home(), "Videos")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
|
||||
elif S_PLATFORM == "darwin":
|
||||
# app data
|
||||
# app_data_dir_base = os.path.expanduser("~/Library/Application Support")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
|
||||
#
|
||||
# cache dir
|
||||
cache_dir_base = os.path.expanduser("~/Library/Caches")
|
||||
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.path.expanduser("~/Movies")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
else:
|
||||
# # app data
|
||||
# app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
|
||||
# if not app_data_dir_base.strip():
|
||||
# app_data_dir_base = os.path.expanduser("~/.config")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
|
||||
#
|
||||
# cache dir
|
||||
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
|
||||
if not cache_dir_base.strip():
|
||||
cache_dir_base = os.path.expanduser("~/.cache")
|
||||
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
|
||||
if not video_dir_base.strip():
|
||||
video_dir_base = os.path.expanduser("~/Videos")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
|
||||
# ensure paths exist
|
||||
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# useful paths
|
||||
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
|
||||
USER_WATCH_HISTORY_PATH = os.path.join(APP_DATA_DIR, "watch_history.json")
|
||||
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
|
||||
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
|
||||
|
||||
|
||||
USER_NAME = os.environ.get("USERNAME", "Anime fun")
|
||||
38
fastanime/core/constants.py
Normal file
38
fastanime/core/constants.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from importlib import resources
|
||||
import os
|
||||
|
||||
APP_NAME = os.environ.get("FASTANIME_APPNAME", "fastanime")
|
||||
|
||||
try:
|
||||
pkg = resources.files("fastanime")
|
||||
|
||||
ASSETS_DIR = pkg / "assets"
|
||||
DEFAULTS = ASSETS_DIR / "defaults"
|
||||
ICONS_DIR = ASSETS_DIR / "icons"
|
||||
|
||||
# rofi files
|
||||
ROFI_THEME_MAIN = DEFAULTS / "rofi" / "main.rasi"
|
||||
ROFI_THEME_INPUT = DEFAULTS / "rofi" / "input.rasi"
|
||||
ROFI_THEME_CONFIRM = DEFAULTS / "rofi" / "confirm.rasi"
|
||||
ROFI_THEME_PREVIEW = DEFAULTS / "rofi" / "preview.rasi"
|
||||
|
||||
# fzf
|
||||
FZF_DEFAULT_OPTS = DEFAULTS / "fzf-opts"
|
||||
|
||||
|
||||
except ModuleNotFoundError:
|
||||
from pathlib import Path
|
||||
|
||||
pkg = Path(__file__).resolve().parent.parent
|
||||
ASSETS_DIR = pkg / "assets"
|
||||
DEFAULTS = ASSETS_DIR / "defaults"
|
||||
ICONS_DIR = ASSETS_DIR / "icons"
|
||||
|
||||
# rofi files
|
||||
ROFI_THEME_MAIN = DEFAULTS / "rofi" / "main.rasi"
|
||||
ROFI_THEME_INPUT = DEFAULTS / "rofi" / "input.rasi"
|
||||
ROFI_THEME_CONFIRM = DEFAULTS / "rofi" / "confirm.rasi"
|
||||
ROFI_THEME_PREVIEW = DEFAULTS / "rofi" / "preview.rasi"
|
||||
|
||||
# fzf
|
||||
FZF_DEFAULT_OPTS = DEFAULTS / "fzf-opts"
|
||||
130
fastanime/core/exceptions.py
Normal file
130
fastanime/core/exceptions.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class FastAnimeError(Exception):
|
||||
"""
|
||||
Base exception for all custom errors raised by the FastAnime library and application.
|
||||
|
||||
Catching this exception will catch any error originating from within this project,
|
||||
distinguishing it from built-in Python errors or third-party library errors.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Configuration and Initialization Errors
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class ConfigError(FastAnimeError):
|
||||
"""
|
||||
Represents an error found in the user's configuration file (config.ini).
|
||||
|
||||
This is typically raised by the ConfigLoader when validation fails.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DependencyNotFoundError(FastAnimeError):
|
||||
"""
|
||||
A required external command-line tool (e.g., ffmpeg, fzf) was not found.
|
||||
|
||||
This indicates a problem with the user's environment setup.
|
||||
"""
|
||||
|
||||
def __init__(self, dependency_name: str, hint: Optional[str] = None):
|
||||
self.dependency_name = dependency_name
|
||||
message = (
|
||||
f"Required dependency '{dependency_name}' not found in your system's PATH."
|
||||
)
|
||||
if hint:
|
||||
message += f"\nHint: {hint}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Provider and Network Errors
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class ProviderError(FastAnimeError):
|
||||
"""
|
||||
Base class for all errors related to an anime provider.
|
||||
|
||||
This allows for catching any provider-related issue while still allowing
|
||||
for more specific error handling of its subclasses.
|
||||
"""
|
||||
|
||||
def __init__(self, provider_name: str, message: str):
|
||||
self.provider_name = provider_name
|
||||
super().__init__(f"[{provider_name.capitalize()}] {message}")
|
||||
|
||||
|
||||
class ProviderAPIError(ProviderError):
|
||||
"""
|
||||
An error occurred while communicating with the provider's API.
|
||||
|
||||
This typically corresponds to network issues, timeouts, or HTTP error
|
||||
status codes like 4xx (client error) or 5xx (server error).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, provider_name: str, http_status: Optional[int] = None, details: str = ""
|
||||
):
|
||||
self.http_status = http_status
|
||||
message = "An API communication error occurred."
|
||||
if http_status:
|
||||
message += f" (Status: {http_status})"
|
||||
if details:
|
||||
message += f" Details: {details}"
|
||||
super().__init__(provider_name, message)
|
||||
|
||||
|
||||
class ProviderParsingError(ProviderError):
|
||||
"""
|
||||
Failed to parse or find expected data in the provider's response.
|
||||
|
||||
This often indicates that the source website's HTML structure or API
|
||||
response schema has changed, and the provider's parser needs to be updated.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Application Logic and Workflow Errors
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class DownloaderError(FastAnimeError):
|
||||
"""
|
||||
An error occurred during the file download or post-processing phase.
|
||||
|
||||
This can be raised by the YTDLPService for issues like failed downloads
|
||||
or ffmpeg merging errors.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidEpisodeRangeError(FastAnimeError, ValueError):
|
||||
"""
|
||||
The user-provided episode range string is malformed or invalid.
|
||||
|
||||
Inherits from ValueError for semantic compatibility but allows for specific
|
||||
catching as a FastAnimeError.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NoStreamsFoundError(ProviderError):
|
||||
"""
|
||||
A provider was successfully queried, but no streamable links were returned.
|
||||
"""
|
||||
|
||||
def __init__(self, provider_name: str, anime_title: str, episode: str):
|
||||
message = f"No streams were found for '{anime_title}' episode {episode}."
|
||||
super().__init__(provider_name, message)
|
||||
477
fastanime/libs/anilist/constants.py
Normal file
477
fastanime/libs/anilist/constants.py
Normal file
@@ -0,0 +1,477 @@
|
||||
SORTS_AVAILABLE = [
|
||||
"ID",
|
||||
"ID_DESC",
|
||||
"TITLE_ROMAJI",
|
||||
"TITLE_ROMAJI_DESC",
|
||||
"TITLE_ENGLISH",
|
||||
"TITLE_ENGLISH_DESC",
|
||||
"TITLE_NATIVE",
|
||||
"TITLE_NATIVE_DESC",
|
||||
"TYPE",
|
||||
"TYPE_DESC",
|
||||
"FORMAT",
|
||||
"FORMAT_DESC",
|
||||
"START_DATE",
|
||||
"START_DATE_DESC",
|
||||
"END_DATE",
|
||||
"END_DATE_DESC",
|
||||
"SCORE",
|
||||
"SCORE_DESC",
|
||||
"POPULARITY",
|
||||
"POPULARITY_DESC",
|
||||
"TRENDING",
|
||||
"TRENDING_DESC",
|
||||
"EPISODES",
|
||||
"EPISODES_DESC",
|
||||
"DURATION",
|
||||
"DURATION_DESC",
|
||||
"STATUS",
|
||||
"STATUS_DESC",
|
||||
"CHAPTERS",
|
||||
"CHAPTERS_DESC",
|
||||
"VOLUMES",
|
||||
"VOLUMES_DESC",
|
||||
"UPDATED_AT",
|
||||
"UPDATED_AT_DESC",
|
||||
"SEARCH_MATCH",
|
||||
"FAVOURITES",
|
||||
"FAVOURITES_DESC",
|
||||
]
|
||||
|
||||
media_statuses_available = [
|
||||
"FINISHED",
|
||||
"RELEASING",
|
||||
"NOT_YET_RELEASED",
|
||||
"CANCELLED",
|
||||
"HIATUS",
|
||||
]
|
||||
seasons_available = ["WINTER", "SPRING", "SUMMER", "FALL"]
|
||||
genres_available = [
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Comedy",
|
||||
"Drama",
|
||||
"Ecchi",
|
||||
"Fantasy",
|
||||
"Horror",
|
||||
"Mahou Shoujo",
|
||||
"Mecha",
|
||||
"Music",
|
||||
"Mystery",
|
||||
"Psychological",
|
||||
"Romance",
|
||||
"Sci-Fi",
|
||||
"Slice of Life",
|
||||
"Sports",
|
||||
"Supernatural",
|
||||
"Thriller",
|
||||
"Hentai",
|
||||
]
|
||||
media_formats_available = [
|
||||
"TV",
|
||||
"TV_SHORT",
|
||||
"MOVIE",
|
||||
"SPECIAL",
|
||||
"OVA",
|
||||
"MUSIC",
|
||||
"NOVEL",
|
||||
"ONE_SHOT",
|
||||
]
|
||||
years_available = [
|
||||
"1900",
|
||||
"1910",
|
||||
"1920",
|
||||
"1930",
|
||||
"1940",
|
||||
"1950",
|
||||
"1960",
|
||||
"1970",
|
||||
"1980",
|
||||
"1990",
|
||||
"2000",
|
||||
"2004",
|
||||
"2005",
|
||||
"2006",
|
||||
"2007",
|
||||
"2008",
|
||||
"2009",
|
||||
"2010",
|
||||
"2011",
|
||||
"2012",
|
||||
"2013",
|
||||
"2014",
|
||||
"2015",
|
||||
"2016",
|
||||
"2017",
|
||||
"2018",
|
||||
"2019",
|
||||
"2020",
|
||||
"2021",
|
||||
"2022",
|
||||
"2023",
|
||||
"2024",
|
||||
"2025",
|
||||
]
|
||||
|
||||
tags_available = {
|
||||
"Cast": ["Polyamorous"],
|
||||
"Cast Main Cast": [
|
||||
"Anti-Hero",
|
||||
"Elderly Protagonist",
|
||||
"Ensemble Cast",
|
||||
"Estranged Family",
|
||||
"Female Protagonist",
|
||||
"Male Protagonist",
|
||||
"Primarily Adult Cast",
|
||||
"Primarily Animal Cast",
|
||||
"Primarily Child Cast",
|
||||
"Primarily Female Cast",
|
||||
"Primarily Male Cast",
|
||||
"Primarily Teen Cast",
|
||||
],
|
||||
"Cast Traits": [
|
||||
"Age Regression",
|
||||
"Agender",
|
||||
"Aliens",
|
||||
"Amnesia",
|
||||
"Angels",
|
||||
"Anthropomorphism",
|
||||
"Aromantic",
|
||||
"Arranged Marriage",
|
||||
"Artificial Intelligence",
|
||||
"Asexual",
|
||||
"Butler",
|
||||
"Centaur",
|
||||
"Chimera",
|
||||
"Chuunibyou",
|
||||
"Clone",
|
||||
"Cosplay",
|
||||
"Cowboys",
|
||||
"Crossdressing",
|
||||
"Cyborg",
|
||||
"Delinquents",
|
||||
"Demons",
|
||||
"Detective",
|
||||
"Dinosaurs",
|
||||
"Disability",
|
||||
"Dissociative Identities",
|
||||
"Dragons",
|
||||
"Dullahan",
|
||||
"Elf",
|
||||
"Fairy",
|
||||
"Femboy",
|
||||
"Ghost",
|
||||
"Goblin",
|
||||
"Gods",
|
||||
"Gyaru",
|
||||
"Hikikomori",
|
||||
"Homeless",
|
||||
"Idol",
|
||||
"Kemonomimi",
|
||||
"Kuudere",
|
||||
"Maids",
|
||||
"Mermaid",
|
||||
"Monster Boy",
|
||||
"Monster Girl",
|
||||
"Nekomimi",
|
||||
"Ninja",
|
||||
"Nudity",
|
||||
"Nun",
|
||||
"Office Lady",
|
||||
"Oiran",
|
||||
"Ojou-sama",
|
||||
"Orphan",
|
||||
"Pirates",
|
||||
"Robots",
|
||||
"Samurai",
|
||||
"Shrine Maiden",
|
||||
"Skeleton",
|
||||
"Succubus",
|
||||
"Tanned Skin",
|
||||
"Teacher",
|
||||
"Tomboy",
|
||||
"Transgender",
|
||||
"Tsundere",
|
||||
"Twins",
|
||||
"Vampire",
|
||||
"Veterinarian",
|
||||
"Vikings",
|
||||
"Villainess",
|
||||
"VTuber",
|
||||
"Werewolf",
|
||||
"Witch",
|
||||
"Yandere",
|
||||
"Zombie",
|
||||
],
|
||||
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
|
||||
"Setting": ["Matriarchy"],
|
||||
"Setting Scene": [
|
||||
"Bar",
|
||||
"Boarding School",
|
||||
"Circus",
|
||||
"Coastal",
|
||||
"College",
|
||||
"Desert",
|
||||
"Dungeon",
|
||||
"Foreign",
|
||||
"Inn",
|
||||
"Konbini",
|
||||
"Natural Disaster",
|
||||
"Office",
|
||||
"Outdoor",
|
||||
"Prison",
|
||||
"Restaurant",
|
||||
"Rural",
|
||||
"School",
|
||||
"School Club",
|
||||
"Snowscape",
|
||||
"Urban",
|
||||
"Work",
|
||||
],
|
||||
"Setting Time": [
|
||||
"Achronological Order",
|
||||
"Anachronism",
|
||||
"Ancient China",
|
||||
"Dystopian",
|
||||
"Historical",
|
||||
"Time Skip",
|
||||
],
|
||||
"Setting Universe": [
|
||||
"Afterlife",
|
||||
"Alternate Universe",
|
||||
"Augmented Reality",
|
||||
"Omegaverse",
|
||||
"Post-Apocalyptic",
|
||||
"Space",
|
||||
"Urban Fantasy",
|
||||
"Virtual World",
|
||||
],
|
||||
"Technical": [
|
||||
"4-koma",
|
||||
"Achromatic",
|
||||
"Advertisement",
|
||||
"Anthology",
|
||||
"CGI",
|
||||
"Episodic",
|
||||
"Flash",
|
||||
"Full CGI",
|
||||
"Full Color",
|
||||
"No Dialogue",
|
||||
"Non-fiction",
|
||||
"POV",
|
||||
"Puppetry",
|
||||
"Rotoscoping",
|
||||
"Stop Motion",
|
||||
],
|
||||
"Theme Action": [
|
||||
"Archery",
|
||||
"Battle Royale",
|
||||
"Espionage",
|
||||
"Fugitive",
|
||||
"Guns",
|
||||
"Martial Arts",
|
||||
"Spearplay",
|
||||
"Swordplay",
|
||||
],
|
||||
"Theme Arts": [
|
||||
"Acting",
|
||||
"Calligraphy",
|
||||
"Classic Literature",
|
||||
"Drawing",
|
||||
"Fashion",
|
||||
"Food",
|
||||
"Makeup",
|
||||
"Photography",
|
||||
"Rakugo",
|
||||
"Writing",
|
||||
],
|
||||
"Theme Arts-Music": [
|
||||
"Band",
|
||||
"Classical Music",
|
||||
"Dancing",
|
||||
"Hip-hop Music",
|
||||
"Jazz Music",
|
||||
"Metal Music",
|
||||
"Musical Theater",
|
||||
"Rock Music",
|
||||
],
|
||||
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
|
||||
"Theme Drama": [
|
||||
"Bullying",
|
||||
"Class Struggle",
|
||||
"Coming of Age",
|
||||
"Conspiracy",
|
||||
"Eco-Horror",
|
||||
"Fake Relationship",
|
||||
"Kingdom Management",
|
||||
"Rehabilitation",
|
||||
"Revenge",
|
||||
"Suicide",
|
||||
"Tragedy",
|
||||
],
|
||||
"Theme Fantasy": [
|
||||
"Alchemy",
|
||||
"Body Swapping",
|
||||
"Cultivation",
|
||||
"Fairy Tale",
|
||||
"Henshin",
|
||||
"Isekai",
|
||||
"Kaiju",
|
||||
"Magic",
|
||||
"Mythology",
|
||||
"Necromancy",
|
||||
"Shapeshifting",
|
||||
"Steampunk",
|
||||
"Super Power",
|
||||
"Superhero",
|
||||
"Wuxia",
|
||||
"Youkai",
|
||||
],
|
||||
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
|
||||
"Theme Game-Card & Board Game": [
|
||||
"Card Battle",
|
||||
"Go",
|
||||
"Karuta",
|
||||
"Mahjong",
|
||||
"Poker",
|
||||
"Shogi",
|
||||
],
|
||||
"Theme Game-Sport": [
|
||||
"Acrobatics",
|
||||
"Airsoft",
|
||||
"American Football",
|
||||
"Athletics",
|
||||
"Badminton",
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Bowling",
|
||||
"Boxing",
|
||||
"Cheerleading",
|
||||
"Cycling",
|
||||
"Fencing",
|
||||
"Fishing",
|
||||
"Fitness",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Handball",
|
||||
"Ice Skating",
|
||||
"Judo",
|
||||
"Lacrosse",
|
||||
"Parkour",
|
||||
"Rugby",
|
||||
"Scuba Diving",
|
||||
"Skateboarding",
|
||||
"Sumo",
|
||||
"Surfing",
|
||||
"Swimming",
|
||||
"Table Tennis",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wrestling",
|
||||
],
|
||||
"Theme Other": [
|
||||
"Adoption",
|
||||
"Animals",
|
||||
"Astronomy",
|
||||
"Autobiographical",
|
||||
"Biographical",
|
||||
"Body Horror",
|
||||
"Cannibalism",
|
||||
"Chibi",
|
||||
"Cosmic Horror",
|
||||
"Crime",
|
||||
"Crossover",
|
||||
"Death Game",
|
||||
"Denpa",
|
||||
"Drugs",
|
||||
"Economics",
|
||||
"Educational",
|
||||
"Environmental",
|
||||
"Ero Guro",
|
||||
"Filmmaking",
|
||||
"Found Family",
|
||||
"Gambling",
|
||||
"Gender Bending",
|
||||
"Gore",
|
||||
"Language Barrier",
|
||||
"LGBTQ+ Themes",
|
||||
"Lost Civilization",
|
||||
"Marriage",
|
||||
"Medicine",
|
||||
"Memory Manipulation",
|
||||
"Meta",
|
||||
"Mountaineering",
|
||||
"Noir",
|
||||
"Otaku Culture",
|
||||
"Pandemic",
|
||||
"Philosophy",
|
||||
"Politics",
|
||||
"Proxy Battle",
|
||||
"Psychosexual",
|
||||
"Reincarnation",
|
||||
"Religion",
|
||||
"Royal Affairs",
|
||||
"Slavery",
|
||||
"Software Development",
|
||||
"Survival",
|
||||
"Terrorism",
|
||||
"Torture",
|
||||
"Travel",
|
||||
"War",
|
||||
],
|
||||
"Theme Other-Organisations": [
|
||||
"Assassins",
|
||||
"Criminal Organization",
|
||||
"Cult",
|
||||
"Firefighters",
|
||||
"Gangs",
|
||||
"Mafia",
|
||||
"Military",
|
||||
"Police",
|
||||
"Triads",
|
||||
"Yakuza",
|
||||
],
|
||||
"Theme Other-Vehicle": [
|
||||
"Aviation",
|
||||
"Cars",
|
||||
"Mopeds",
|
||||
"Motorcycles",
|
||||
"Ships",
|
||||
"Tanks",
|
||||
"Trains",
|
||||
],
|
||||
"Theme Romance": [
|
||||
"Age Gap",
|
||||
"Bisexual",
|
||||
"Boys' Love",
|
||||
"Female Harem",
|
||||
"Heterosexual",
|
||||
"Love Triangle",
|
||||
"Male Harem",
|
||||
"Matchmaking",
|
||||
"Mixed Gender Harem",
|
||||
"Teens' Love",
|
||||
"Unrequited Love",
|
||||
"Yuri",
|
||||
],
|
||||
"Theme Sci Fi": [
|
||||
"Cyberpunk",
|
||||
"Space Opera",
|
||||
"Time Loop",
|
||||
"Time Manipulation",
|
||||
"Tokusatsu",
|
||||
],
|
||||
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
|
||||
"Theme Slice of Life": [
|
||||
"Agriculture",
|
||||
"Cute Boys Doing Cute Things",
|
||||
"Cute Girls Doing Cute Things",
|
||||
"Family Life",
|
||||
"Horticulture",
|
||||
"Iyashikei",
|
||||
"Parenthood",
|
||||
],
|
||||
}
|
||||
tags_available_list = []
|
||||
for tag_category, tags_in_category in tags_available.items():
|
||||
tags_available_list.extend(tags_in_category)
|
||||
@@ -9,4 +9,4 @@ anime_sources = {
|
||||
"nyaa": "api.Nyaa",
|
||||
"yugen": "api.Yugen",
|
||||
}
|
||||
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]
|
||||
SERVERS_AVAILABLE = ["top", *ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]
|
||||
|
||||
280
tests/test_config_loader.py
Normal file
280
tests/test_config_loader.py
Normal file
@@ -0,0 +1,280 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastanime.cli.config.loader import ConfigLoader
|
||||
from fastanime.cli.config.model import AppConfig, GeneralConfig
|
||||
from fastanime.core.exceptions import ConfigError
|
||||
|
||||
# ==============================================================================
|
||||
# Pytest Fixtures
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config_dir(tmp_path: Path) -> Path:
|
||||
"""Creates a temporary directory for config files for each test."""
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
return config_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_config_content() -> str:
|
||||
"""Provides the content for a valid, complete config.ini file."""
|
||||
return """
|
||||
[general]
|
||||
provider = hianime
|
||||
selector = fzf
|
||||
auto_select_anime_result = false
|
||||
icons = true
|
||||
preview = text
|
||||
image_renderer = icat
|
||||
preferred_language = romaji
|
||||
sub_lang = jpn
|
||||
manga_viewer = feh
|
||||
downloads_dir = ~/MyAnimeDownloads
|
||||
check_for_updates = false
|
||||
cache_requests = false
|
||||
max_cache_lifetime = 01:00:00
|
||||
normalize_titles = false
|
||||
discord = true
|
||||
|
||||
[stream]
|
||||
player = vlc
|
||||
quality = 720
|
||||
translation_type = dub
|
||||
server = gogoanime
|
||||
auto_next = true
|
||||
continue_from_watch_history = false
|
||||
preferred_watch_history = remote
|
||||
auto_skip = true
|
||||
episode_complete_at = 95
|
||||
ytdlp_format = best
|
||||
|
||||
[anilist]
|
||||
per_page = 25
|
||||
sort_by = TRENDING_DESC
|
||||
default_media_list_tracking = track
|
||||
force_forward_tracking = false
|
||||
recent = 10
|
||||
|
||||
[fzf]
|
||||
opts = --reverse --height=80%
|
||||
header_color = 255,0,0
|
||||
preview_header_color = 0,255,0
|
||||
preview_separator_color = 0,0,255
|
||||
|
||||
[rofi]
|
||||
theme_main = /path/to/main.rasi
|
||||
theme_preview = /path/to/preview.rasi
|
||||
theme_confirm = /path/to/confirm.rasi
|
||||
theme_input = /path/to/input.rasi
|
||||
|
||||
[mpv]
|
||||
args = --fullscreen
|
||||
pre_args =
|
||||
disable_popen = false
|
||||
force_window = no
|
||||
use_python_mpv = true
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def partial_config_content() -> str:
|
||||
"""Provides content for a partial config file to test default value handling."""
|
||||
return """
|
||||
[general]
|
||||
provider = hianime
|
||||
|
||||
[stream]
|
||||
quality = 720
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def malformed_ini_content() -> str:
|
||||
"""Provides content with invalid .ini syntax that configparser will fail on."""
|
||||
return "[general\nkey = value"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Test Class for ConfigLoader
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class TestConfigLoader:
|
||||
def test_load_creates_and_loads_default_config(self, temp_config_dir: Path):
|
||||
"""
|
||||
GIVEN no config file exists.
|
||||
WHEN the ConfigLoader loads configuration.
|
||||
THEN it should create a default config file and load default values.
|
||||
"""
|
||||
# ARRANGE
|
||||
config_path = temp_config_dir / "config.ini"
|
||||
assert not config_path.exists()
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT: Mock click.echo to prevent printing during tests
|
||||
with patch("click.echo"):
|
||||
config = loader.load()
|
||||
|
||||
# ASSERT: File creation and content
|
||||
assert config_path.exists()
|
||||
created_content = config_path.read_text(encoding="utf-8")
|
||||
assert "[general]" in created_content
|
||||
assert "# Configuration for general application behavior" in created_content
|
||||
|
||||
# ASSERT: Loaded object has default values.
|
||||
# Direct object comparison can be brittle, so we test key attributes.
|
||||
default_config = AppConfig.model_validate({})
|
||||
assert config.general.provider == default_config.general.provider
|
||||
assert config.stream.quality == default_config.stream.quality
|
||||
assert config.anilist.per_page == default_config.anilist.per_page
|
||||
# A full comparison might fail due to how Path objects or multi-line strings
|
||||
# are instantiated vs. read from a file. Testing key values is more robust.
|
||||
|
||||
def test_load_from_valid_full_config(
|
||||
self, temp_config_dir: Path, valid_config_content: str
|
||||
):
|
||||
"""
|
||||
GIVEN a valid and complete config file exists.
|
||||
WHEN the ConfigLoader loads it.
|
||||
THEN it should return a correctly parsed AppConfig object with overridden values.
|
||||
"""
|
||||
# ARRANGE
|
||||
config_path = temp_config_dir / "config.ini"
|
||||
config_path.write_text(valid_config_content)
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT
|
||||
config = loader.load()
|
||||
|
||||
# ASSERT
|
||||
assert isinstance(config, AppConfig)
|
||||
assert config.general.provider == "hianime"
|
||||
assert config.general.auto_select_anime_result is False
|
||||
assert config.general.downloads_dir == Path("~/MyAnimeDownloads")
|
||||
assert config.stream.quality == "720"
|
||||
assert config.stream.player == "vlc"
|
||||
assert config.anilist.per_page == 25
|
||||
assert config.fzf.opts == "--reverse --height=80%"
|
||||
assert config.mpv.use_python_mpv is True
|
||||
|
||||
def test_load_from_partial_config(
|
||||
self, temp_config_dir: Path, partial_config_content: str
|
||||
):
|
||||
"""
|
||||
GIVEN a partial config file exists.
|
||||
WHEN the ConfigLoader loads it.
|
||||
THEN it should load specified values and use defaults for missing ones.
|
||||
"""
|
||||
# ARRANGE
|
||||
config_path = temp_config_dir / "config.ini"
|
||||
config_path.write_text(partial_config_content)
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT
|
||||
config = loader.load()
|
||||
|
||||
# ASSERT: Specified values are loaded correctly
|
||||
assert config.general.provider == "hianime"
|
||||
assert config.stream.quality == "720"
|
||||
|
||||
# ASSERT: Other values fall back to defaults
|
||||
default_general = GeneralConfig()
|
||||
assert config.general.selector == default_general.selector
|
||||
assert config.general.icons is False
|
||||
assert config.stream.player == "mpv"
|
||||
assert config.anilist.per_page == 15
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
("true", True),
|
||||
("false", False),
|
||||
("yes", True),
|
||||
("no", False),
|
||||
("on", True),
|
||||
("off", False),
|
||||
("1", True),
|
||||
("0", False),
|
||||
],
|
||||
)
|
||||
def test_boolean_value_handling(
|
||||
self, temp_config_dir: Path, value: str, expected: bool
|
||||
):
|
||||
"""
|
||||
GIVEN a config file with various boolean string representations.
|
||||
WHEN the ConfigLoader loads it.
|
||||
THEN pydantic should correctly parse them into boolean values.
|
||||
"""
|
||||
# ARRANGE
|
||||
content = f"[general]\nauto_select_anime_result = {value}\n"
|
||||
config_path = temp_config_dir / "config.ini"
|
||||
config_path.write_text(content)
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT
|
||||
config = loader.load()
|
||||
|
||||
# ASSERT
|
||||
assert config.general.auto_select_anime_result is expected
|
||||
|
||||
def test_load_raises_error_for_malformed_ini(
|
||||
self, temp_config_dir: Path, malformed_ini_content: str
|
||||
):
|
||||
"""
|
||||
GIVEN a config file has invalid .ini syntax that configparser will reject.
|
||||
WHEN the ConfigLoader loads it.
|
||||
THEN it should raise a ConfigError.
|
||||
"""
|
||||
# ARRANGE
|
||||
config_path = temp_config_dir / "config.ini"
|
||||
config_path.write_text(malformed_ini_content)
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT & ASSERT
|
||||
with pytest.raises(ConfigError, match="Error parsing configuration file"):
|
||||
loader.load()
|
||||
|
||||
def test_load_raises_error_for_invalid_value(self, temp_config_dir: Path):
|
||||
"""
|
||||
GIVEN a config file contains a value that fails model validation.
|
||||
WHEN the ConfigLoader loads it.
|
||||
THEN it should raise a ConfigError with a helpful message.
|
||||
"""
|
||||
# ARRANGE
|
||||
invalid_content = "[stream]\nquality = 9001\n"
|
||||
config_path = temp_config_dir / "config.ini"
|
||||
config_path.write_text(invalid_content)
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT & ASSERT
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
loader.load()
|
||||
|
||||
# Check for a user-friendly error message
|
||||
assert "Configuration error" in str(exc_info.value)
|
||||
assert "stream.quality" in str(exc_info.value)
|
||||
|
||||
def test_load_raises_error_if_default_config_cannot_be_written(
|
||||
self, temp_config_dir: Path
|
||||
):
|
||||
"""
|
||||
GIVEN the default config file cannot be written due to permissions.
|
||||
WHEN the ConfigLoader attempts to create it.
|
||||
THEN it should raise a ConfigError.
|
||||
"""
|
||||
# ARRANGE
|
||||
config_path = temp_config_dir / "unwritable_dir" / "config.ini"
|
||||
loader = ConfigLoader(config_path=config_path)
|
||||
|
||||
# ACT & ASSERT: Mock Path.write_text to simulate a permissions error
|
||||
with patch("pathlib.Path.write_text", side_effect=PermissionError):
|
||||
with patch("click.echo"): # Mock echo to keep test output clean
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
loader.load()
|
||||
|
||||
assert "Could not create default configuration file" in str(exc_info.value)
|
||||
assert "Please check permissions" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user