diff --git a/fastanime/cli/cli.py b/fastanime/cli/cli.py index d529ee3..c07e449 100644 --- a/fastanime/cli/cli.py +++ b/fastanime/cli/cli.py @@ -3,9 +3,8 @@ from click.core import ParameterSource from .. import __version__ from ..core.config import AppConfig -from ..core.constants import APP_NAME +from ..core.constants import APP_NAME, USER_CONFIG_PATH from .config import ConfigLoader -from .constants import USER_CONFIG_PATH from .options import options_from_model from .utils.lazyloader import LazyGroup from .utils.logging import setup_logging diff --git a/fastanime/cli/commands/config.py b/fastanime/cli/commands/config.py index d2c9dce..a6dc866 100644 --- a/fastanime/cli/commands/config.py +++ b/fastanime/cli/commands/config.py @@ -12,6 +12,9 @@ from ...core.config import AppConfig # Edit your config in your default editor # NB: If it opens vim or vi exit with `:q` fastanime config +\b + # Start the interactive configuration wizard + fastanime config --interactive \b # get the path of the config file fastanime config --path @@ -42,10 +45,17 @@ from ...core.config import AppConfig help="Persist all the config options passed to fastanime to your config file", is_flag=True, ) +@click.option( + "--interactive", + "-i", + is_flag=True, + help="Start the interactive configuration wizard.", +) @click.pass_obj -def config(user_config: AppConfig, path, view, desktop_entry, update): +def config(user_config: AppConfig, path, view, desktop_entry, update, interactive): + from ...core.constants import USER_CONFIG_PATH from ..config.generate import generate_config_ini_from_app_model - from ..constants import USER_CONFIG_PATH + from ..config.interactive_editor import InteractiveConfigEditor if path: print(USER_CONFIG_PATH) @@ -53,6 +63,12 @@ def config(user_config: AppConfig, path, view, desktop_entry, update): print(generate_config_ini_from_app_model(user_config)) elif desktop_entry: _generate_desktop_entry() + elif interactive: + editor = InteractiveConfigEditor(current_config=user_config) + new_config = editor.run() + with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file: + file.write(generate_config_ini_from_app_model(new_config)) + click.echo(f"Configuration saved successfully to {USER_CONFIG_PATH}") elif update: with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file: file.write(generate_config_ini_from_app_model(user_config)) @@ -75,7 +91,7 @@ def _generate_desktop_entry(): from rich.prompt import Confirm from ... import __version__ - from ..constants import APP_NAME, ICON_PATH, PLATFORM + from ...core.constants import APP_NAME, ICON_PATH, PLATFORM FASTANIME_EXECUTABLE = shutil.which("fastanime") if FASTANIME_EXECUTABLE: diff --git a/fastanime/cli/config/generate.py b/fastanime/cli/config/generate.py index ebe6ced..6ec4eb7 100644 --- a/fastanime/cli/config/generate.py +++ b/fastanime/cli/config/generate.py @@ -2,7 +2,7 @@ import textwrap from pathlib import Path from ...core.config import AppConfig -from ..constants import APP_ASCII_ART +from ...core.constants import APP_ASCII_ART # The header for the config file. config_asci = "\n".join([f"# {line}" for line in APP_ASCII_ART.split()]) diff --git a/fastanime/cli/config/interactive_editor.py b/fastanime/cli/config/interactive_editor.py new file mode 100644 index 0000000..be12212 --- /dev/null +++ b/fastanime/cli/config/interactive_editor.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import textwrap +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin + +from InquirerPy import inquirer +from InquirerPy.validator import NumberValidator +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from rich import print + +from ...core.config.model import AppConfig + + +class InteractiveConfigEditor: + """A wizard to guide users through setting up their configuration interactively.""" + + def __init__(self, current_config: AppConfig): + self.config = current_config.model_copy(deep=True) # Work on a copy + + def run(self) -> AppConfig: + """Starts the interactive configuration wizard.""" + print( + "[bold cyan]Welcome to the FastAnime Interactive Configurator![/bold cyan]" + ) + print("Let's set up your experience. Press Ctrl+C at any time to exit.") + print("Current values will be shown as defaults.") + + try: + for section_name, section_model in self.config: + if not isinstance(section_model, BaseModel): + continue + + if not inquirer.confirm( + message=f"Configure '{section_name.title()}' settings?", + default=True, + ).execute(): + continue + + self._prompt_for_section(section_name, section_model) + + print("\n[bold green]Configuration complete![/bold green]") + return self.config + + except KeyboardInterrupt: + print("\n[bold yellow]Configuration cancelled.[/bold yellow]") + # Return original config if user cancels + return self.config + + def _prompt_for_section(self, section_name: str, section_model: BaseModel): + """Generates prompts for all fields in a given config section.""" + print(f"\n--- [bold magenta]{section_name.title()} Settings[/bold magenta] ---") + + for field_name, field_info in section_model.model_fields.items(): + # Skip complex multi-line fields as agreed + if section_name == "fzf" and field_name in ["opts", "header_ascii_art"]: + continue + + current_value = getattr(section_model, field_name) + prompt = self._create_prompt(field_name, field_info, current_value) + + if prompt: + new_value = prompt.execute() + + # Explicitly cast the value to the correct type before setting it. + field_type = field_info.annotation + if new_value is not None: + if field_type is Path: + new_value = Path(new_value).expanduser() + elif field_type is int: + new_value = int(new_value) + elif field_type is float: + new_value = float(new_value) + + setattr(section_model, field_name, new_value) + + def _create_prompt( + self, field_name: str, field_info: FieldInfo, current_value: Any + ): + """Creates the appropriate InquirerPy prompt for a given Pydantic field.""" + field_type = field_info.annotation + help_text = textwrap.fill( + field_info.description or "No description available.", width=80 + ) + message = f"{field_name.replace('_', ' ').title()}:" + + # Boolean fields + if field_type is bool: + return inquirer.confirm( + message=message, default=current_value, long_instruction=help_text + ) + + # Literal (Choice) fields + if hasattr(field_type, "__origin__") and get_origin(field_type) is Literal: + choices = list(get_args(field_type)) + return inquirer.select( + message=message, + choices=choices, + default=current_value, + long_instruction=help_text, + ) + + # Numeric fields + if field_type is int: + return inquirer.number( + message=message, + default=int(current_value), + long_instruction=help_text, + min_allowed=getattr(field_info, "gt", None) + or getattr(field_info, "ge", None), + max_allowed=getattr(field_info, "lt", None) + or getattr(field_info, "le", None), + validate=NumberValidator(), + ) + if field_type is float: + return inquirer.number( + message=message, + default=float(current_value), + float_allowed=True, + long_instruction=help_text, + ) + + # Path fields + if field_type is Path: + # Use text prompt for paths to allow '~' expansion, as FilePathPrompt can be tricky + return inquirer.text( + message=message, default=str(current_value), long_instruction=help_text + ) + + # String fields + if field_type is str: + # Check for 'examples' to provide choices + if hasattr(field_info, "examples") and field_info.examples: + return inquirer.fuzzy( + message=message, + choices=field_info.examples, + default=str(current_value), + long_instruction=help_text, + ) + return inquirer.text( + message=message, default=str(current_value), long_instruction=help_text + ) + + return None diff --git a/fastanime/cli/config/loader.py b/fastanime/cli/config/loader.py index 6e9dbfc..0539a03 100644 --- a/fastanime/cli/config/loader.py +++ b/fastanime/cli/config/loader.py @@ -2,12 +2,14 @@ import configparser from pathlib import Path import click +from InquirerPy import inquirer from pydantic import ValidationError from ...core.config import AppConfig +from ...core.constants import USER_CONFIG_PATH from ...core.exceptions import ConfigError -from ..constants import USER_CONFIG_PATH from .generate import generate_config_ini_from_app_model +from .interactive_editor import InteractiveConfigEditor class ConfigLoader: @@ -35,23 +37,39 @@ class ConfigLoader: 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(): - config_ini_content = generate_config_ini_from_app_model( - AppConfig().model_validate({}) + def _handle_first_run(self) -> AppConfig: + """Handles the configuration process when no config file is found.""" + click.echo( + "[bold yellow]Welcome to FastAnime![/bold yellow] No configuration file found." + ) + choice = inquirer.select( + message="How would you like to proceed?", + choices=[ + "Use default settings (Recommended for new users)", + "Configure settings interactively", + ], + default="Use default settings (Recommended for new users)", + ).execute() + + if "interactively" in choice: + editor = InteractiveConfigEditor(AppConfig()) + app_config = editor.run() + else: + app_config = AppConfig() + + config_ini_content = generate_config_ini_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") + click.echo( + f"Configuration file created at: [green]{self.config_path}[/green]" ) - try: - self.config_path.parent.mkdir(parents=True, exist_ok=True) - self.config_path.write_text(config_ini_content, 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 {self.config_path!s}. Please check permissions. Error: {e}", - ) + except Exception as e: + raise ConfigError( + f"Could not create configuration file at {self.config_path!s}. Please check permissions. Error: {e}", + ) + + return app_config def load(self) -> AppConfig: """ @@ -63,7 +81,8 @@ class ConfigLoader: Raises: click.ClickException: If the configuration file contains validation errors. """ - self._create_default_if_not_exists() + if not self.config_path.exists(): + return self._handle_first_run() try: self.parser.read(self.config_path, encoding="utf-8") diff --git a/fastanime/cli/options.py b/fastanime/cli/options.py index 4170871..6029309 100644 --- a/fastanime/cli/options.py +++ b/fastanime/cli/options.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined -from .config.model import OtherConfig +from ..core.config.model import OtherConfig TYPE_MAP = { str: click.STRING, diff --git a/fastanime/cli/utils/logging.py b/fastanime/cli/utils/logging.py index d36ba66..c4717ef 100644 --- a/fastanime/cli/utils/logging.py +++ b/fastanime/cli/utils/logging.py @@ -2,7 +2,7 @@ import logging from rich.traceback import install as rich_install -from ..constants import LOG_FILE_PATH +from ...core.constants import LOG_FILE_PATH def setup_logging(log: bool, log_file: bool, rich_traceback: bool) -> None: diff --git a/fastanime/libs/providers/anime/animepahe/__init__.py b/fastanime/libs/providers/anime/animepahe/__init__.py index e418cb8..8b13789 100644 --- a/fastanime/libs/providers/anime/animepahe/__init__.py +++ b/fastanime/libs/providers/anime/animepahe/__init__.py @@ -1 +1 @@ -from .provider import AnimePahe +