feat: interactively edit config

This commit is contained in:
Benexl
2025-07-12 18:57:02 +03:00
parent 85368393fc
commit d279cc70b9
8 changed files with 206 additions and 27 deletions

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
from .provider import AnimePahe