mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
feat: interactively edit config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()])
|
||||
|
||||
145
fastanime/cli/config/interactive_editor.py
Normal file
145
fastanime/cli/config/interactive_editor.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .provider import AnimePahe
|
||||
|
||||
|
||||
Reference in New Issue
Block a user